修复创作弹窗与生成器引用缓存

未开放工具弹窗补充平台主题背景
画布生成器缓存引用按当前账号过滤
新画布工具栏引导改用文档图片
补充弹窗、引导图和引用归属回归测试
This commit is contained in:
2026-06-30 21:50:16 +08:00
parent e0e3348f79
commit d50ecbc492
13 changed files with 203 additions and 46 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

@@ -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: '期待一下' }));
@@ -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"
@@ -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',
@@ -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<string, CanvasLayerResourceMetadata> = 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<string, CanvasLayerResourceMetadata> = 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<string, CanvasLayerResourceMetadata>,
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<string, CanvasLayerResourceMetadata> = 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<string, CanvasLayerResourceMetadata> = 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;
@@ -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画布工具栏',
});
@@ -308,6 +308,7 @@ export function ImageCanvasEditorView() {
const startupIntentConsumedRef = useRef(false);
const selectedLayerIdRef = useRef<string | null>(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,
@@ -453,9 +453,9 @@ export function ImageCanvasStageView({
aria-live="polite"
>
<img
src="/branding/mobile-home-welcome-taonier-ip.png"
src="/branding/taonier-toolbar-guide-bubble.png"
alt=""
className="image-canvas-editor__toolbar-guide-ip"
className="image-canvas-editor__toolbar-guide-art"
/>
<span>便</span>
</div>
@@ -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,
@@ -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(
<GenerationWorkflowHarness currentUserId="user-a" />,
);
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(<GenerationWorkflowHarness currentUserId="user-b" />);
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(<GenerationWorkflowHarness />);
@@ -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<string, unknown>;
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<CharacterReferenceImage | null>(() =>
readCachedGenerationReference(LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY),
readCachedGenerationReference(
LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY,
currentUserId,
),
);
const [initialIconSpecReference] = useState<CharacterReferenceImage | null>(
() => readCachedGenerationReference(LAST_ICON_SPEC_REFERENCE_CACHE_KEY),
() =>
readCachedGenerationReference(
LAST_ICON_SPEC_REFERENCE_CACHE_KEY,
currentUserId,
),
);
const currentUserIdRef = useRef(currentUserId);
const lastCharacterSpecReferenceRef = useRef<CharacterReferenceImage | null>(
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]);
@@ -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<string | null>(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,
+19 -36
View File
@@ -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;
}