修复画布素材删除级联
保留素材库 sourceResourceId 并写入画布图层 删除素材时用 sourceResourceId 匹配项目资源图层 补充级联删除持久化回归测试和排障记录
This commit is contained in:
@@ -15,6 +15,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 图片画布素材库删除要匹配 sourceResourceId
|
||||
|
||||
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
|
||||
- 原因:生成素材进入账号级素材库时可能通过 `editor_asset.sourceResourceId` 指向原项目资源;如果前端素材库映射和级联删除只比较 `sourceAssetId`、`assetObjectId`、`objectKey` 或 `src`,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。
|
||||
- 处理:`EditorAsset` 必须保留 `sourceResourceId`;从素材库添加到画布时继续写入图层;删除素材时同时比较 `layer.resourceId` / `layer.sourceResourceId` 与 `asset.sourceResourceId`。
|
||||
- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。
|
||||
|
||||
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
|
||||
|
||||
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
|
||||
|
||||
@@ -77,7 +77,13 @@ vi.mock('../../services/assetReadUrlService', async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSignedAssetReadUrl: getSignedAssetReadUrlMock,
|
||||
getSignedAssetReadUrl: (
|
||||
...args: Parameters<typeof actual.getSignedAssetReadUrl>
|
||||
) =>
|
||||
getSignedAssetReadUrlMock(...args) ??
|
||||
Promise.resolve(
|
||||
`https://signed.example.com/${args[0]?.objectKey ?? 'asset'}`,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -123,21 +129,36 @@ function createSizedPngFile(name: string, width: number, height: number) {
|
||||
function createTestImageAsset({
|
||||
assetId,
|
||||
label,
|
||||
imageSrc,
|
||||
objectKey,
|
||||
assetObjectId,
|
||||
sourceType = 'uploaded',
|
||||
sourceResourceId,
|
||||
}: {
|
||||
assetId: string;
|
||||
label: string;
|
||||
imageSrc?: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
sourceType?: 'uploaded' | 'generated' | 'mock_generated';
|
||||
sourceResourceId?: string | null;
|
||||
}) {
|
||||
const objectKey = `generated-character-drafts/editor/asset-library/image/${assetId}.png`;
|
||||
const resolvedObjectKey =
|
||||
objectKey === undefined
|
||||
? `generated-character-drafts/editor/asset-library/image/${assetId}.png`
|
||||
: objectKey;
|
||||
return {
|
||||
assetId,
|
||||
folderId: 'project',
|
||||
label,
|
||||
imageSrc: `/${objectKey}`,
|
||||
objectKey,
|
||||
assetObjectId: `assetobj-${assetId}`,
|
||||
imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''),
|
||||
objectKey: resolvedObjectKey,
|
||||
assetObjectId:
|
||||
assetObjectId === undefined ? `assetobj-${assetId}` : assetObjectId,
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded' as const,
|
||||
sourceType,
|
||||
sourceResourceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -787,6 +808,84 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
|
||||
});
|
||||
|
||||
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '生成资源项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-generated-a',
|
||||
resourceId: 'resource-generated-a',
|
||||
title: '生成资源A',
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-generated-a',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/editor-test/project/generated-a.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
createTestImageAsset({
|
||||
assetId: 'asset-generated-a',
|
||||
label: '生成资源A',
|
||||
imageSrc: '/editor-test/asset-library/copied-a.png',
|
||||
objectKey: null,
|
||||
assetObjectId: null,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-generated-a',
|
||||
}),
|
||||
],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '删除素材生成资源A' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((layout) =>
|
||||
layout.layers.every(
|
||||
(item: { layerId?: unknown }) =>
|
||||
item.layerId !== 'layer-generated-a',
|
||||
),
|
||||
);
|
||||
expect(savedLayoutWithoutDeletedLayer).toBeTruthy();
|
||||
});
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a');
|
||||
});
|
||||
|
||||
it('saves a library asset layer right after creating its canvas resource', async () => {
|
||||
const user = userEvent.setup();
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -139,6 +139,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
persisted: true,
|
||||
objectKey: 'oss/asset-1.png',
|
||||
assetObjectId: 'object-1',
|
||||
sourceResourceId: 'resource-source-1',
|
||||
};
|
||||
|
||||
const layer = createLayerFromAsset(
|
||||
@@ -158,11 +159,43 @@ describe('ImageCanvasEditorModel', () => {
|
||||
objectKey: 'oss/asset-1.png',
|
||||
assetObjectId: 'object-1',
|
||||
sourceAssetId: 'asset-1',
|
||||
sourceResourceId: 'resource-source-1',
|
||||
});
|
||||
expect(layer.x).toBe(-18);
|
||||
expect(layer.y).toBe(12);
|
||||
});
|
||||
|
||||
it('preserves source resource ids from the persisted asset library', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-generated',
|
||||
folderId: 'project',
|
||||
label: '生成素材',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-generated',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(library.assets[0]).toMatchObject({
|
||||
id: 'asset-generated',
|
||||
sourceResourceId: 'resource-generated',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-generated',
|
||||
|
||||
@@ -179,6 +179,7 @@ export function createLayerFromAsset(
|
||||
taskId: asset.taskId,
|
||||
objectKey: asset.objectKey,
|
||||
assetObjectId: asset.assetObjectId,
|
||||
sourceResourceId: asset.sourceResourceId,
|
||||
durationSeconds: asset.durationSeconds,
|
||||
sourceAssetId: asset.id,
|
||||
assetKind: asset.assetKind ?? assetKind,
|
||||
@@ -709,6 +710,7 @@ export function mapAssetLibrarySnapshot(
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
sourceResourceId: asset.sourceResourceId ?? undefined,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
@@ -970,11 +972,21 @@ export function escapeCssIdentifier(value: string) {
|
||||
}
|
||||
|
||||
export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
|
||||
const assetSourceResourceId = asset.sourceResourceId?.trim();
|
||||
const assetObjectKey = asset.objectKey?.trim().replace(/^\/+/u, '');
|
||||
const layerObjectKey = layer.objectKey?.trim().replace(/^\/+/u, '');
|
||||
const assetSrc = asset.src.trim().replace(/^\/+/u, '');
|
||||
const layerSrc = layer.src.trim().replace(/^\/+/u, '');
|
||||
return (
|
||||
layer.sourceAssetId === asset.id ||
|
||||
Boolean(
|
||||
assetSourceResourceId &&
|
||||
(layer.resourceId === assetSourceResourceId ||
|
||||
layer.sourceResourceId === assetSourceResourceId),
|
||||
) ||
|
||||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
|
||||
Boolean(asset.objectKey && layer.objectKey === asset.objectKey) ||
|
||||
layer.src === asset.src
|
||||
Boolean(assetObjectKey && layerObjectKey === assetObjectKey) ||
|
||||
Boolean(assetSrc && layerSrc === assetSrc)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export type EditorAsset = {
|
||||
taskId?: string;
|
||||
objectKey?: string;
|
||||
assetObjectId?: string;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
|
||||
@@ -148,10 +148,7 @@ function AssetCanvasBridgeHarness({
|
||||
|
||||
function AssetCleanupHarness({
|
||||
deletedAssets = [],
|
||||
}: {
|
||||
deletedAssets?: EditorAsset[];
|
||||
}) {
|
||||
const [layers, setLayers] = useState([
|
||||
initialLayers = [
|
||||
createLayer({ id: 'linked', sourceAssetId: 'asset-1' }),
|
||||
createLayer({
|
||||
id: 'kept',
|
||||
@@ -159,7 +156,12 @@ function AssetCleanupHarness({
|
||||
src: 'data:image/png;base64,asset-2',
|
||||
sourceAssetId: 'asset-2',
|
||||
}),
|
||||
]);
|
||||
],
|
||||
}: {
|
||||
deletedAssets?: EditorAsset[];
|
||||
initialLayers?: CanvasLayer[];
|
||||
}) {
|
||||
const [layers, setLayers] = useState(initialLayers);
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('linked');
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']);
|
||||
const cleanup = useImageCanvasAssetLayerCleanup({
|
||||
@@ -249,4 +251,42 @@ describe('useImageCanvasAssetCanvasBridge', () => {
|
||||
expect(screen.getByTestId('selected').textContent).toBe('-');
|
||||
expect(screen.getByTestId('selected-many').textContent).toBe('kept');
|
||||
});
|
||||
|
||||
it('removes canvas layers linked by the asset source resource id', () => {
|
||||
render(
|
||||
<AssetCleanupHarness
|
||||
deletedAssets={[
|
||||
{
|
||||
...defaultAsset,
|
||||
id: 'asset-generated',
|
||||
src: '/generated-character-drafts/editor/asset-library/copied.png',
|
||||
assetObjectId: undefined,
|
||||
sourceResourceId: 'resource-generated',
|
||||
},
|
||||
]}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'linked-by-resource',
|
||||
resourceId: 'resource-generated',
|
||||
src: '/generated-character-drafts/editor/project/generated.png',
|
||||
assetObjectId: undefined,
|
||||
sourceAssetId: null,
|
||||
}),
|
||||
createLayer({
|
||||
id: 'kept',
|
||||
resourceId: 'resource-kept',
|
||||
src: '/generated-character-drafts/editor/project/kept.png',
|
||||
assetObjectId: undefined,
|
||||
sourceAssetId: null,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '清理素材' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe('kept');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user