diff --git a/public/branding/taonier-toolbar-guide-bubble.png b/public/branding/taonier-toolbar-guide-bubble.png new file mode 100644 index 000000000..6d5aa2598 Binary files /dev/null and b/public/branding/taonier-toolbar-guide-bubble.png differ diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 0460537e1..d4fcae6ae 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -262,6 +262,7 @@ describe('CreationLandingView', () => { const dialog = screen.getByRole('dialog', { name: '抱歉' }); expect(dialog.textContent).toContain('功能还在调试中'); expect(dialog.textContent).toContain('暂未开放'); + expect(dialog.closest('.platform-theme--light')).toBeTruthy(); expect(createEditorProjectMock).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: '期待一下' })); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index e007fb68a..6c41cfb94 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -762,7 +762,7 @@ export function CreationLandingView({ showHeader={false} showCloseButton={false} size="sm" - overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4" + overlayClassName="platform-theme platform-theme--light platform-mobile-home-welcome-overlay !items-center !p-4" panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog" bodyClassName="platform-mobile-home-welcome-dialog__body" footerClassName="platform-mobile-home-welcome-dialog__footer" diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index ec8ad5ca5..d57049cbe 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -642,6 +642,45 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('drops restored generator references owned by another user', () => { + const dialog: CanvasGenerationDialogState = { + id: 'generation-dialog-owner', + mode: 'generate', + prompt: '不应复用别人素材', + status: 'idle', + composerOpen: true, + generationReferences: [ + { + id: 'foreign-reference', + label: '别人账号的素材', + src: 'data:image/png;base64,foreign', + resourceId: 'resource-foreign', + }, + ], + }; + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [dialog], + }), + new Map([ + [ + 'resource-foreign', + { + imageSrc: '/read/foreign.png', + objectKey: 'generated/foreign.png', + ownerUserId: 'user-b', + }, + ], + ]), + 'user-a', + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.generationReferences).toEqual([]); + }); + it('snaps moving layers to nearby canvas and layer guides', () => { const movingLayer: CanvasLayer = { id: 'moving', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 435a3c146..3636d07c0 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -315,6 +315,18 @@ function isPersistedReferenceAssetId(assetId: string | null | undefined) { return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-')); } +function normalizedCurrentUserId(userId: string | null | undefined) { + return userId?.trim() || null; +} + +function isReferencePointerSrc(src: string | null | undefined) { + const normalizedSrc = src?.trim() ?? ''; + return ( + normalizedSrc.startsWith('ref:project-resource:') || + normalizedSrc.startsWith('ref:asset:') + ); +} + function resolveReferencePointer(reference: CharacterReferenceImage) { const resourceId = reference.resourceId?.trim(); if (isPersistedReferenceResourceId(resourceId)) { @@ -435,6 +447,7 @@ export function isCanvasGenerationDialogLayoutItem( export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), + currentUserId?: string | null, ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; @@ -444,7 +457,11 @@ export function splitCanvasLayoutItems( items.forEach((item) => { if (isCanvasGenerationDialogLayoutItem(item)) { - const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById); + const dialog = hydrateCanvasGenerationDialog( + item.dialog, + resourcesById, + currentUserId, + ); if (dialog) { generationDialogs.push(dialog); } @@ -459,6 +476,7 @@ export function splitCanvasLayoutItems( export function hydrateCanvasGenerationDialog( value: unknown, resourcesById: Map = new Map(), + currentUserId?: string | null, ): CanvasGenerationDialogState | null { if (!value || typeof value !== 'object') { return null; @@ -488,22 +506,27 @@ export function hydrateCanvasGenerationDialog( specReference: hydrateCharacterReference( snapshot.specReference, resourcesById, + currentUserId, ), generationReferences: hydrateCharacterReferences( snapshot.generationReferences, resourcesById, + currentUserId, ), characterSpecReference: hydrateCharacterReference( snapshot.characterSpecReference, resourcesById, + currentUserId, ), characterReferences: hydrateCharacterReferences( snapshot.characterReferences, resourcesById, + currentUserId, ), iconSpecReference: hydrateCharacterReference( snapshot.iconSpecReference, resourcesById, + currentUserId, ), iconDescriptions: Array.isArray(snapshot.iconDescriptions) ? snapshot.iconDescriptions.filter( @@ -520,10 +543,12 @@ export function hydrateCanvasGenerationDialog( publicationReferences: hydrateCharacterReferences( snapshot.publicationReferences, resourcesById, + currentUserId, ), uiDesignSpecReference: hydrateCharacterReference( snapshot.uiDesignSpecReference, resourcesById, + currentUserId, ), imageModel: stringOrUndefined(snapshot.imageModel), videoModel: @@ -775,6 +800,7 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind { export type CanvasLayerResourceMetadata = { resourceId?: string | null; + ownerUserId?: string | null; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; @@ -1171,9 +1197,16 @@ function hydrateSpecFormValues( function resolveHydratedReferenceResource( resourceId: string | undefined, resourcesById: Map, + currentUserId?: string | null, ) { if (resourceId) { - return resourcesById.get(resourceId); + const resource = resourcesById.get(resourceId); + const currentUser = normalizedCurrentUserId(currentUserId); + const ownerUserId = resource?.ownerUserId?.trim() || null; + if (currentUser && ownerUserId && ownerUserId !== currentUser) { + return null; + } + return resource; } return undefined; } @@ -1181,6 +1214,7 @@ function resolveHydratedReferenceResource( function hydrateCharacterReference( value: unknown, resourcesById: Map = new Map(), + currentUserId?: string | null, ): CharacterReferenceImage | null { if (!value || typeof value !== 'object') { return null; @@ -1199,7 +1233,18 @@ function hydrateCharacterReference( const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined; const durationSeconds = numberFromSnapshot(snapshot.durationSeconds, 0) || undefined; - const resource = resolveHydratedReferenceResource(resourceId, resourcesById); + const resource = resolveHydratedReferenceResource( + resourceId, + resourcesById, + currentUserId, + ); + if ( + resource === null || + (currentUserId && resourceId && !resource) || + (!resource && isReferencePointerSrc(src)) + ) { + return null; + } const objectKey = stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey); const assetObjectId = @@ -1226,10 +1271,15 @@ function hydrateCharacterReference( function hydrateCharacterReferences( value: unknown, resourcesById: Map = new Map(), + currentUserId?: string | null, ): CharacterReferenceImage[] | undefined { return Array.isArray(value) ? value.flatMap((reference) => { - const hydrated = hydrateCharacterReference(reference, resourcesById); + const hydrated = hydrateCharacterReference( + reference, + resourcesById, + currentUserId, + ); return hydrated ? [hydrated] : []; }) : undefined; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 833db1ccd..a6d3fdc1e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -147,6 +147,11 @@ describe('ImageCanvasEditorView', () => { expect( await screen.findByText('工具栏在这里,快试着随便做点什么'), ).toBeTruthy(); + expect( + document + .querySelector('.image-canvas-editor__toolbar-guide-art') + ?.getAttribute('src'), + ).toBe('/branding/taonier-toolbar-guide-bubble.png'); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏', }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 767acb49c..49e2d2cad 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -308,6 +308,7 @@ export function ImageCanvasEditorView() { const startupIntentConsumedRef = useRef(false); const selectedLayerIdRef = useRef(null); const showRechargeEntry = shouldShowRechargeEntry(); + const currentEditorUserId = authUi?.user?.id ?? null; const [isToolbarGuideVisible, setIsToolbarGuideVisible] = useState(false); useEffect(() => { @@ -993,6 +994,7 @@ export function ImageCanvasEditorView() { viewport, isViewportInteracting, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, + currentUserId: currentEditorUserId, openEditorLoginModal, }); const applyGeneratedProjectSnapshot = useCallback( @@ -1127,6 +1129,7 @@ export function ImageCanvasEditorView() { persistGeneratedAsset, persistUpdatedLayerResource, projectId, + currentUserId: currentEditorUserId, assetFolderId: activeUploadFolderId, upsertGeneratedAsset, applyProjectSnapshot: applyGeneratedProjectSnapshot, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index de970ed37..586c277db 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -453,9 +453,9 @@ export function ImageCanvasStageView({ aria-live="polite" > 工具栏在这里,快试着随便做点什么 diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index d8d578d0e..a429a0ee2 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -96,6 +96,7 @@ type ImageCanvasGenerationSurfaceOptions = { persistGeneratedAsset?: (layer: CanvasLayer) => void; persistUpdatedLayerResource?: (layer: CanvasLayer) => void; projectId?: string | null; + currentUserId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; @@ -235,6 +236,7 @@ export function useImageCanvasGenerationSurface({ persistGeneratedAsset, persistUpdatedLayerResource, projectId, + currentUserId, assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, @@ -271,6 +273,7 @@ export function useImageCanvasGenerationSurface({ persistGeneratedAsset, persistUpdatedLayerResource, projectId, + currentUserId, assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 086fa87a5..478ea29af 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -102,11 +102,13 @@ function GenerationWorkflowHarness({ initialLayers = [createLayer()], initialViewport = { x: 10, y: 20, scale: 2 }, projectId, + currentUserId, applyProjectSnapshot, }: { initialLayers?: CanvasLayer[]; initialViewport?: { x: number; y: number; scale: number }; projectId?: string; + currentUserId?: string; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; @@ -156,6 +158,7 @@ function GenerationWorkflowHarness({ setMetadataLayer, setImageContextMenu, projectId, + currentUserId, applyProjectSnapshot, }); @@ -1295,6 +1298,33 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('does not reuse cached spec references written by another user', () => { + const firstRender = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); + fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); + + firstRender.unmount(); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); + expect(screen.getByTestId('character-spec-reference').textContent).toBe( + '-', + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); + expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); + + fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' })); + expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe( + '-', + ); + }); + it('reuses uploaded character and icon specs when opening matching material dialogs again', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 162f9e03f..6aa996fd4 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -283,6 +283,7 @@ function readOptionalCachedNullableString( function readCachedGenerationReference( key: string, + currentUserId?: string | null, ): CharacterReferenceImage | null { const storage = getLocalGenerationReferenceStorage(); if (!storage) { @@ -298,6 +299,13 @@ function readCachedGenerationReference( return null; } const record = parsedValue as Record; + const normalizedCurrentUserId = currentUserId?.trim(); + const ownerUserId = + typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : ''; + if (normalizedCurrentUserId && ownerUserId !== normalizedCurrentUserId) { + storage.removeItem(key); + return null; + } const id = readOptionalCachedString(record, 'id'); const label = readOptionalCachedString(record, 'label'); const src = readOptionalCachedString(record, 'src'); @@ -345,13 +353,18 @@ function readCachedGenerationReference( function writeCachedGenerationReference( key: string, reference: CharacterReferenceImage, + currentUserId?: string | null, ) { const storage = getLocalGenerationReferenceStorage(); if (!storage) { return; } try { - storage.setItem(key, JSON.stringify(reference)); + const ownerUserId = currentUserId?.trim(); + storage.setItem( + key, + JSON.stringify(ownerUserId ? { ...reference, ownerUserId } : reference), + ); } catch { // 中文注释:本地缓存只提升下次新建素材的便捷性,写入失败不阻断生成流程。 } @@ -448,6 +461,7 @@ type GenerationWorkflowOptions = { persistGeneratedAsset?: (layer: CanvasLayer) => void; persistUpdatedLayerResource?: (layer: CanvasLayer) => void; projectId?: string | null; + currentUserId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; @@ -481,6 +495,7 @@ export function useImageCanvasGenerationWorkflow({ persistGeneratedAsset, persistUpdatedLayerResource, projectId, + currentUserId, assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, @@ -564,11 +579,19 @@ export function useImageCanvasGenerationWorkflow({ ); const [initialCharacterSpecReference] = useState(() => - readCachedGenerationReference(LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY), + readCachedGenerationReference( + LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY, + currentUserId, + ), ); const [initialIconSpecReference] = useState( - () => readCachedGenerationReference(LAST_ICON_SPEC_REFERENCE_CACHE_KEY), + () => + readCachedGenerationReference( + LAST_ICON_SPEC_REFERENCE_CACHE_KEY, + currentUserId, + ), ); + const currentUserIdRef = useRef(currentUserId); const lastCharacterSpecReferenceRef = useRef( initialCharacterSpecReference, ); @@ -589,6 +612,18 @@ export function useImageCanvasGenerationWorkflow({ cropExpandPanelRef.current = cropExpandPanel; quickEditSelectionStateRef.current = quickEditSelectionState; + useEffect(() => { + currentUserIdRef.current = currentUserId; + lastCharacterSpecReferenceRef.current = readCachedGenerationReference( + LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY, + currentUserId, + ); + lastIconSpecReferenceRef.current = readCachedGenerationReference( + LAST_ICON_SPEC_REFERENCE_CACHE_KEY, + currentUserId, + ); + }, [currentUserId]); + const updateQuickEditSelectionState = useCallback( ( updater: ( @@ -613,6 +648,7 @@ export function useImageCanvasGenerationWorkflow({ writeCachedGenerationReference( LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY, generateDialog.characterSpecReference, + currentUserIdRef.current, ); return; } @@ -621,6 +657,7 @@ export function useImageCanvasGenerationWorkflow({ writeCachedGenerationReference( LAST_ICON_SPEC_REFERENCE_CACHE_KEY, generateDialog.iconSpecReference, + currentUserIdRef.current, ); return; } @@ -632,6 +669,7 @@ export function useImageCanvasGenerationWorkflow({ writeCachedGenerationReference( LAST_ICON_SPEC_REFERENCE_CACHE_KEY, generateDialog.uiDesignSpecReference, + currentUserIdRef.current, ); } }, [generateDialog]); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index bcc2b75ab..ff629c8e4 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -282,6 +282,7 @@ export function useImageCanvasProjectPersistence({ viewport, isViewportInteracting, canAccessProtectedData, + currentUserId, openEditorLoginModal, }: { refs: ImageCanvasProjectPersistenceRefs; @@ -291,6 +292,7 @@ export function useImageCanvasProjectPersistence({ viewport: CanvasViewport; isViewportInteracting: boolean; canAccessProtectedData: boolean; + currentUserId?: string | null; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; }) { const projectIdRef = useRef(null); @@ -590,6 +592,7 @@ export function useImageCanvasProjectPersistence({ { imageSrc: resource.imageSrc, resourceId: resource.resourceId, + ownerUserId: resource.ownerUserId, objectKey: resource.objectKey, assetObjectId: resource.assetObjectId, width: resource.width, @@ -612,6 +615,7 @@ export function useImageCanvasProjectPersistence({ const { layerItems, generationDialogs } = splitCanvasLayoutItems( project.layers, resourcesById, + currentUserId, ); const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) @@ -625,6 +629,7 @@ export function useImageCanvasProjectPersistence({ }, [ clearPendingProjectLayoutSave, + currentUserId, refs, restoreCanvasGenerationDialogs, selectSingleLayer, diff --git a/src/index.css b/src/index.css index ea28bd9d4..6f7269448 100644 --- a/src/index.css +++ b/src/index.css @@ -6837,46 +6837,36 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__toolbar-guide { position: absolute; left: 50%; - bottom: 4.45rem; + bottom: 3.65rem; z-index: 12; - display: flex; - align-items: flex-end; - gap: 0; - max-width: min(28rem, calc(100% - 2rem)); + width: min(38rem, calc(100% - 2rem)); + aspect-ratio: 2 / 1; color: #ffffff; pointer-events: none; transform: translateX(-50%); } -.image-canvas-editor__toolbar-guide-ip { - width: clamp(5.4rem, 12vw, 8.4rem); - height: clamp(5.4rem, 12vw, 8.4rem); +.image-canvas-editor__toolbar-guide-art { + display: block; + width: 100%; + height: 100%; object-fit: contain; filter: drop-shadow(0 16px 22px rgba(112, 57, 30, 0.18)); } .image-canvas-editor__toolbar-guide span { - position: relative; - margin-bottom: 0.9rem; - margin-left: -0.55rem; - border-radius: 0.9rem; - background: #f28a2e; - padding: 1rem 1.25rem; - font-size: 0.92rem; + position: absolute; + top: 49%; + right: 7%; + left: 36%; + margin: 0; + color: #ffffff; + font-size: 1rem; font-weight: 850; line-height: 1.45; - box-shadow: 0 16px 34px rgba(150, 78, 26, 0.2); -} - -.image-canvas-editor__toolbar-guide span::before { - position: absolute; - top: 1.35rem; - left: -0.62rem; - width: 1.1rem; - height: 1.1rem; - background: #f28a2e; - content: ""; - transform: rotate(45deg); + text-align: center; + text-shadow: 0 2px 7px rgba(127, 65, 25, 0.2); + transform: translateY(-50%); } .image-canvas-editor__bottom-toolbar button[aria-pressed='true'] { @@ -9236,19 +9226,12 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__toolbar-guide { right: 0.85rem; left: 0.85rem; - bottom: 4.75rem; - max-width: none; + bottom: 4.25rem; + width: auto; transform: none; } - .image-canvas-editor__toolbar-guide-ip { - width: 5.25rem; - height: 5.25rem; - } - .image-canvas-editor__toolbar-guide span { - margin-bottom: 0.65rem; - padding: 0.82rem 0.95rem; font-size: 0.82rem; }