修复画板提示与失效项目跳转

画板参考图选择提示改为持续显示并支持手动关闭。

显式项目访问失效时同步切回项目页状态。

补充提示关闭和项目失效回退测试。
This commit is contained in:
2026-07-05 10:45:55 +08:00
parent 963b662e23
commit ecac0dc3fc
9 changed files with 120 additions and 25 deletions
@@ -110,6 +110,10 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'publication-cover',
];
type ImageCanvasEditorViewProps = {
onProjectAccessLost?: () => void;
};
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
@@ -278,7 +282,9 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
};
}
export function ImageCanvasEditorView() {
export function ImageCanvasEditorView({
onProjectAccessLost,
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
@@ -996,6 +1002,7 @@ export function ImageCanvasEditorView() {
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
onProjectAccessLost,
});
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
@@ -311,6 +311,8 @@ describe('useImageCanvasGenerationSurface', () => {
expect(screen.getByRole('alert').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }));
expect(screen.queryByRole('alert')).toBeNull();
});
it('anchors the music menu to the music tool and closes after hover leaves', () => {
@@ -11,6 +11,7 @@ import {
useRef,
} from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import {
PlatformFloatingMenu,
@@ -481,7 +482,15 @@ export function useImageCanvasGenerationSurface({
className="image-canvas-editor__reference-pick-toast"
role="alert"
>
{generationWorkflow.referencePickWarning}
<span>{generationWorkflow.referencePickWarning}</span>
<button
type="button"
className="image-canvas-editor__reference-pick-toast-close"
aria-label="关闭提示"
onClick={generationWorkflow.clearReferencePickWarning}
>
<X aria-hidden="true" size={14} strokeWidth={2.4} />
</button>
</PlatformRuntimeStatusToast>
) : null}
<ImageCanvasGenerationComposerView
@@ -1291,6 +1291,30 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
});
it('keeps reference pick warnings visible until the user takes a follow-up action', () => {
vi.useFakeTimers();
try {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
act(() => {
vi.advanceTimersByTime(10_000);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
} finally {
vi.useRealTimers();
}
});
it('restores the last picked character spec from local cache after remounting the editor', () => {
const firstRender = render(<GenerationWorkflowHarness />);
@@ -189,7 +189,6 @@ const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastCharacterSpecReference';
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
'genarrative.imageCanvas.lastIconSpecReference';
const REFERENCE_PICK_WARNING_DISMISS_MS = 2600;
const INVALID_CHARACTER_SPEC_WARNING =
'选择的图片不是角色规范图,请选择生成规范里的角色规范。';
const INVALID_ICON_SPEC_WARNING =
@@ -697,16 +696,6 @@ export function useImageCanvasGenerationWorkflow({
generateDialog?.mode,
]);
useEffect(() => {
if (!referencePickWarning) {
return undefined;
}
const warningTimer = window.setTimeout(() => {
setReferencePickWarning(null);
}, REFERENCE_PICK_WARNING_DISMISS_MS);
return () => window.clearTimeout(warningTimer);
}, [referencePickWarning]);
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
@@ -89,9 +89,11 @@ function createDeferred<T>() {
function ProjectPersistenceHarness({
canAccessProtectedData = true,
initialGenerationDialogs = [],
onProjectAccessLost,
}: {
canAccessProtectedData?: boolean;
initialGenerationDialogs?: CanvasGenerationDialogState[];
onProjectAccessLost?: () => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [generationDialogs, setGenerationDialogs] = useState<
@@ -151,6 +153,7 @@ function ProjectPersistenceHarness({
isViewportInteracting,
canAccessProtectedData,
openEditorLoginModal: openEditorLoginModalRef.current,
onProjectAccessLost,
});
return (
@@ -1355,4 +1358,29 @@ describe('useImageCanvasProjectPersistence', () => {
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
},
);
it('notifies the shell to leave the canvas when an explicit project becomes inaccessible', async () => {
const onProjectAccessLost = vi.fn();
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=missing-project',
);
loadEditorProjectMock.mockRejectedValueOnce(
new ApiClientError({
message: '项目不存在',
status: 404,
code: 'not_found',
}),
);
render(
<ProjectPersistenceHarness onProjectAccessLost={onProjectAccessLost} />,
);
await waitFor(() => {
expect(onProjectAccessLost).toHaveBeenCalledTimes(1);
});
expect(loadEditorProjectMock).toHaveBeenCalledWith('missing-project');
});
});
@@ -78,6 +78,19 @@ type ImageCanvasProjectPersistenceSetters = {
) => void;
};
type ImageCanvasProjectPersistenceOptions = {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
isViewportInteracting: boolean;
canAccessProtectedData: boolean;
currentUserId?: string | null;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
onProjectAccessLost?: () => void;
};
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
@@ -284,17 +297,8 @@ export function useImageCanvasProjectPersistence({
canAccessProtectedData,
currentUserId,
openEditorLoginModal,
}: {
refs: ImageCanvasProjectPersistenceRefs;
setters: ImageCanvasProjectPersistenceSetters;
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
viewport: CanvasViewport;
isViewportInteracting: boolean;
canAccessProtectedData: boolean;
currentUserId?: string | null;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
}) {
onProjectAccessLost,
}: ImageCanvasProjectPersistenceOptions) {
const projectIdRef = useRef<string | null>(null);
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
@@ -704,6 +708,10 @@ export function useImageCanvasProjectPersistence({
}
if (projectIdFromQuery && isEditorProjectAccessError(error)) {
removeEditorProjectSessionCache(projectIdFromQuery);
if (onProjectAccessLost) {
onProjectAccessLost();
return;
}
replaceAppHistoryPath('/project');
}
});
@@ -715,6 +723,7 @@ export function useImageCanvasProjectPersistence({
canAccessProtectedData,
applyProjectSnapshot,
createProjectResourceForLayer,
onProjectAccessLost,
openEditorLoginModal,
]);
@@ -119,6 +119,7 @@ import { useHostNetworkOnline } from '../../hooks/useHostNetworkOnline';
import {
buildPublicWorkStagePath,
pushAppHistoryPath,
replaceAppHistoryPath,
resolvePathForSelectionStage,
} from '../../routing/appPageRoutes';
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
@@ -3757,6 +3758,11 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('project');
}, [setSelectionStage]);
const replaceWithProjectGallery = useCallback(() => {
replaceAppHistoryPath('/project');
setSelectionStage('project');
}, [setSelectionStage]);
const openCreationCommunity = useCallback(() => {
setIsCreationCommunityOpen(true);
}, []);
@@ -15716,7 +15722,9 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载编辑器..." />}
>
<ImageCanvasEditorView />
<ImageCanvasEditorView
onProjectAccessLost={replaceWithProjectGallery}
/>
</Suspense>
</motion.div>
)}
+19
View File
@@ -8944,6 +8944,25 @@ button.image-canvas-editor__reference-chip:disabled {
pointer-events: none;
}
.image-canvas-editor__reference-pick-toast-close {
display: inline-grid;
width: 1.35rem;
height: 1.35rem;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentColor 16%, transparent);
color: inherit;
cursor: pointer;
pointer-events: auto;
}
.image-canvas-editor__reference-pick-toast-close:hover,
.image-canvas-editor__reference-pick-toast-close:focus-visible {
background: color-mix(in srgb, currentColor 26%, transparent);
}
.image-canvas-editor__generate-submit {
justify-self: end;
}