修复编辑器定价持久化与资源预览

将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移
修复快速编辑支付弹窗期间框选显示和交互冻结
补齐后台图片放大以及视频音频资源预览
收紧主站和 External 资源换签授权并记录管理员跨用户审计
固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口
加固运行时身份轮换、bootstrap secret 与生产构建发布门禁
同步生成绑定、定向测试、运维脚本和项目文档
This commit is contained in:
2026-07-10 18:10:31 +08:00
parent 5c9b5ef69e
commit 187d7735b9
79 changed files with 7457 additions and 468 deletions
@@ -573,6 +573,158 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
});
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: '泥点余额 1,234泥点',
}),
);
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 () => {
const openLoginModal = vi.fn();
@@ -1478,9 +1630,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();
});
@@ -519,6 +519,12 @@ export function ImageCanvasEditorView({
requestLogin: () => authUiRef.current?.openLoginModal(),
currentUser: authUi?.user ?? null,
});
const isAccountPaymentModalOpen =
isRewardCodeOpen ||
isRechargeOpen ||
Boolean(nativeWechatPayment) ||
Boolean(rechargePaymentResult) ||
Boolean(wechatRechargeOrderConfirmationState);
useEffect(() => {
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
@@ -1347,6 +1353,8 @@ export function ImageCanvasEditorView({
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
cancelQuickEditSelectionPointer,
cancelUiAssetExtraction,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
@@ -1581,6 +1589,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) {
@@ -1777,6 +1809,7 @@ export function ImageCanvasEditorView({
);
useImageCanvasKeyboardShortcuts({
isInteractionPaused: isAccountPaymentModalOpen,
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
@@ -1848,6 +1881,9 @@ export function ImageCanvasEditorView({
if (isEditablePasteTarget(event.target)) {
return;
}
if (isAccountPaymentModalOpen) {
return;
}
const didPasteCanvasClipboard = pasteCanvasClipboard();
if (didPasteCanvasClipboard) {
event.preventDefault();
@@ -1865,7 +1901,7 @@ export function ImageCanvasEditorView({
return () => {
window.removeEventListener('paste', handleClipboardPaste);
};
}, [addUploadedFiles, pasteCanvasClipboard]);
}, [addUploadedFiles, isAccountPaymentModalOpen, pasteCanvasClipboard]);
useEffect(() => {
const blockBrowserZoom = (event: WheelEvent) => {
@@ -2005,7 +2041,7 @@ export function ImageCanvasEditorView({
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
onOpenWallet: openRechargeOrRewardCodeModal,
onOpenWallet: openAccountPaymentModal,
onOpenAccount: () => {
if (authUi?.user) {
authUi.openAccountModal();
@@ -2019,6 +2055,7 @@ export function ImageCanvasEditorView({
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
isInteractionPaused: isAccountPaymentModalOpen,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -2040,8 +2077,12 @@ export function ImageCanvasEditorView({
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
uiAssetExtractionState: isAccountPaymentModalOpen
? null
: uiAssetExtractionState,
uiAssetExtractionSourceLayer: isAccountPaymentModalOpen
? null
: uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
generationComposerStyle,
@@ -45,6 +45,7 @@ export type ImageCanvasStageViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
isInteractionPaused?: boolean;
isPanning: boolean;
effectiveTool: CanvasTool;
canvasBackgroundColor: string;
@@ -196,6 +197,7 @@ export function ImageCanvasStageView({
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
isInteractionPaused = false,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -316,16 +318,20 @@ export function ImageCanvasStageView({
<div
ref={canvasViewportRef}
className={`image-canvas-editor__viewport ${isPanning ? 'image-canvas-editor__viewport--panning' : ''} image-canvas-editor__viewport--tool-${effectiveTool}`}
style={{ backgroundColor: canvasBackgroundColor }}
style={{
backgroundColor: canvasBackgroundColor,
pointerEvents: isInteractionPaused ? 'none' : undefined,
}}
aria-label="画布工作区"
onPointerDown={onCanvasPointerDown}
onPointerMove={onCanvasPointerMove}
onPointerUp={onCanvasPointerUp}
onPointerCancel={onCanvasPointerUp}
onDragOver={onCanvasDragOver}
onDragLeave={onCanvasDragLeave}
onDrop={onCanvasDrop}
onContextMenu={onCanvasContextMenu}
aria-disabled={isInteractionPaused || undefined}
onPointerDown={isInteractionPaused ? undefined : onCanvasPointerDown}
onPointerMove={isInteractionPaused ? undefined : onCanvasPointerMove}
onPointerUp={isInteractionPaused ? undefined : onCanvasPointerUp}
onPointerCancel={isInteractionPaused ? undefined : onCanvasPointerUp}
onDragOver={isInteractionPaused ? undefined : onCanvasDragOver}
onDragLeave={isInteractionPaused ? undefined : onCanvasDragLeave}
onDrop={isInteractionPaused ? undefined : onCanvasDrop}
onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu}
>
{uploadDropTarget === 'canvas' ? (
<div
@@ -1650,14 +1650,17 @@ export function useImageCanvasGenerationWorkflow({
setActiveSidebarPanel('layers');
} catch (error) {
if (backgroundRemovalDialogId) {
updateCanvasGenerationDialogById(backgroundRemovalDialogId, (dialog) => ({
...dialog,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '去除背景失败',
}));
updateCanvasGenerationDialogById(
backgroundRemovalDialogId,
(dialog) => ({
...dialog,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '去除背景失败',
}),
);
}
throw error;
}
@@ -2120,6 +2123,17 @@ export function useImageCanvasGenerationWorkflow({
});
}, []);
const cancelUiAssetExtractionPointer = useCallback(() => {
setUiAssetExtractionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: null,
}
: currentState,
);
}, []);
const endQuickEditSelectionPointer = useCallback(() => {
const currentSelectionState = quickEditSelectionStateRef.current;
if (!currentSelectionState?.draftMark) {
@@ -2179,6 +2193,17 @@ export function useImageCanvasGenerationWorkflow({
);
}, [setGenerateDialog, setQuickEditPanel, updateQuickEditSelectionState]);
const cancelQuickEditSelectionPointer = useCallback(() => {
updateQuickEditSelectionState((currentState) =>
currentState?.draftMark
? {
...currentState,
draftMark: null,
}
: currentState,
);
}, [updateQuickEditSelectionState]);
useEffect(() => {
if (!quickEditSelectionState) {
return;
@@ -2349,6 +2374,8 @@ export function useImageCanvasGenerationWorkflow({
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
cancelQuickEditSelectionPointer,
cancelUiAssetExtraction,
submitUiAssetExtraction,
characterAnimationPanel: effectiveCharacterAnimationPanel,
@@ -2453,6 +2480,8 @@ export function useImageCanvasGenerationWorkflow({
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
cancelQuickEditSelectionPointer,
cancelUiAssetExtraction,
submitUiAssetExtraction,
setEffectiveCharacterAnimationPanel,
@@ -1,7 +1,12 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { useRef, useState } from 'react';
import {
type Dispatch,
type SetStateAction,
useRef,
useState,
} from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
@@ -58,6 +63,7 @@ function KeyboardShortcutsHarness({
initialGenerateDialog = null,
initialQuickEditPanel = null,
initialTool = 'select',
isInteractionPaused = false,
undoCanvasChange = vi.fn(),
redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
@@ -79,12 +85,15 @@ function KeyboardShortcutsHarness({
startProjectRename = vi.fn(),
exportCanvasAssets = vi.fn(),
closeEditorChromePanels = vi.fn(),
setIsSpacePanning: setIsSpacePanningOverride,
setShiftPressed: setShiftPressedOverride,
}: {
selectedLayerId?: string | null;
selectedLayerIds?: string[];
initialGenerateDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialTool?: CanvasTool;
isInteractionPaused?: boolean;
undoCanvasChange?: () => void;
redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
@@ -108,6 +117,8 @@ function KeyboardShortcutsHarness({
startProjectRename?: () => void;
exportCanvasAssets?: () => void;
closeEditorChromePanels?: () => void;
setIsSpacePanning?: Dispatch<SetStateAction<boolean>>;
setShiftPressed?: Dispatch<SetStateAction<boolean>>;
}) {
const [activeTool, setActiveTool] = useState<CanvasTool>(initialTool);
const [generateDialog, setGenerateDialogState] =
@@ -141,8 +152,8 @@ function KeyboardShortcutsHarness({
const [, setIsUiDesignSpecMenuOpen] = useState(true);
const [, setIsPickingUiDesignSpecFromCanvas] =
useState(true);
const [isSpacePanning, setIsSpacePanning] = useState(false);
const [shiftPressed, setShiftPressed] = useState(false);
const [isSpacePanning, setIsSpacePanningState] = useState(false);
const [shiftPressed, setShiftPressedState] = useState(false);
const generateDialogRef = useRef<GenerateDialogState | null>(generateDialog);
const selectedLayerIdRef = useRef<string | null>(selectedLayerId);
const selectedLayerIdsRef = useRef<string[]>(selectedLayerIds);
@@ -151,6 +162,7 @@ function KeyboardShortcutsHarness({
selectedLayerIdsRef.current = selectedLayerIds;
useImageCanvasKeyboardShortcuts({
isInteractionPaused,
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
@@ -200,8 +212,8 @@ function KeyboardShortcutsHarness({
setIsPickingIconSpecFromCanvas,
setIsUiDesignSpecMenuOpen,
setIsPickingUiDesignSpecFromCanvas,
setIsSpacePanning,
setShiftPressed,
setIsSpacePanning: setIsSpacePanningOverride ?? setIsSpacePanningState,
setShiftPressed: setShiftPressedOverride ?? setShiftPressedState,
});
return (
@@ -483,6 +495,82 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(screen.getByTestId('space-panning').textContent).toBe('false');
});
it('pauses canvas shortcuts and clears held interaction state while account dialogs are open', () => {
const selectAllCanvasObjects = vi.fn();
const deleteSelectedCanvasObjects = vi.fn();
const clearCanvasSelection = vi.fn();
const switchTool = vi.fn();
const setIsSpacePanning = vi.fn();
const setShiftPressed = vi.fn();
const { rerender } = render(
<KeyboardShortcutsHarness
selectedLayerIds={['layer-selected']}
selectAllCanvasObjects={selectAllCanvasObjects}
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
clearCanvasSelection={clearCanvasSelection}
switchTool={switchTool}
setIsSpacePanning={setIsSpacePanning}
setShiftPressed={setShiftPressed}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.keyDown(window, { key: ' ', code: 'Space' });
});
expect(setShiftPressed).toHaveBeenLastCalledWith(true);
expect(setIsSpacePanning).toHaveBeenLastCalledWith(true);
rerender(
<KeyboardShortcutsHarness
selectedLayerIds={['layer-selected']}
isInteractionPaused
selectAllCanvasObjects={selectAllCanvasObjects}
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
clearCanvasSelection={clearCanvasSelection}
switchTool={switchTool}
setIsSpacePanning={setIsSpacePanning}
setShiftPressed={setShiftPressed}
/>,
);
expect(setShiftPressed).toHaveBeenLastCalledWith(false);
expect(setIsSpacePanning).toHaveBeenLastCalledWith(false);
const selectAllEvent = new KeyboardEvent('keydown', {
key: 'a',
code: 'KeyA',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
const deleteEvent = new KeyboardEvent('keydown', {
key: 'Delete',
code: 'Delete',
bubbles: true,
cancelable: true,
});
const escapeEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
});
window.dispatchEvent(selectAllEvent);
window.dispatchEvent(deleteEvent);
window.dispatchEvent(escapeEvent);
fireEvent.keyDown(window, { key: 'v', code: 'KeyV' });
expect(selectAllCanvasObjects).not.toHaveBeenCalled();
expect(deleteSelectedCanvasObjects).not.toHaveBeenCalled();
expect(clearCanvasSelection).not.toHaveBeenCalled();
expect(switchTool).not.toHaveBeenCalled();
expect(selectAllEvent.defaultPrevented).toBe(false);
expect(deleteEvent.defaultPrevented).toBe(false);
expect(escapeEvent.defaultPrevented).toBe(false);
});
it('nudges selected canvas objects with arrow keys', () => {
const nudgeSelectedCanvasObjects = vi.fn();
render(
@@ -9,6 +9,7 @@ import type {
} from './ImageCanvasEditorTypes';
type UseImageCanvasKeyboardShortcutsOptions = {
isInteractionPaused?: boolean;
generateDialogRef: RefObject<GenerateDialogState | null>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef?: RefObject<string[]>;
@@ -156,6 +157,7 @@ function getArrowDelta(event: KeyboardEvent) {
}
export function useImageCanvasKeyboardShortcuts({
isInteractionPaused = false,
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
@@ -202,6 +204,14 @@ export function useImageCanvasKeyboardShortcuts({
setIsSpacePanning,
setShiftPressed,
}: UseImageCanvasKeyboardShortcutsOptions) {
useEffect(() => {
if (!isInteractionPaused) {
return;
}
setShiftPressed(false);
setIsSpacePanning(false);
}, [isInteractionPaused, setIsSpacePanning, setShiftPressed]);
useEffect(() => {
const closeTransientEditorPanels = () => {
closeEditorChromePanels();
@@ -253,6 +263,9 @@ export function useImageCanvasKeyboardShortcuts({
};
const handleKeyDown = (event: KeyboardEvent) => {
if (isInteractionPaused) {
return;
}
if (
isCtrlShortcut(event) &&
event.code === 'KeyZ' &&
@@ -461,6 +474,9 @@ export function useImageCanvasKeyboardShortcuts({
setIsSpacePanning(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (isInteractionPaused) {
return;
}
if (event.key === 'Shift') {
setShiftPressed(false);
}
@@ -501,6 +517,7 @@ export function useImageCanvasKeyboardShortcuts({
exportCanvasAssets,
fitLayers,
generateDialogRef,
isInteractionPaused,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,