拆分图片画布素材库状态模型

新增图片画布素材库状态 hook

补充素材库状态 hook 单测

收口主视图素材库文件夹与选择逻辑

更新图片画布前端拆分跟踪文档
This commit is contained in:
2026-06-17 06:17:34 +08:00
parent 37a738e271
commit eb583107f4
5 changed files with 1109 additions and 518 deletions
+1
View File
@@ -124,3 +124,4 @@
- 2026-06-17 前端拆分第七阶段:新增 `useCanvasHistory`,把画布历史快照、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态从主视图抽出;主视图只在具体动作前捕获历史,并注入恢复快照后的菜单 / hover / 框选 / 拖拽清理。验证命令:`npm run test -- src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck`
- 2026-06-17 前端拆分第八阶段:新增 `useImageCanvasProjectPersistence`,把项目加载、`projectId` 状态、未就绪资源队列、工程资源创建、资源创建后即时保存和 450ms 自动保存从主视图抽出;新增 hook 单测锁定新增图层资源创建后保存真实 `resourceId` 的 layout。验证命令:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck`
- 2026-06-17 前端拆分第九阶段:新增 `useCanvasGenerationDialogs`,把画布生成对象的 active / inactive 注册表、归档、激活、按 id 更新 / 删除、按图层清理和生成中最新占位框查询从主视图抽出;主视图继续保留生成提交、结果落图、quick edit 和跨图层副作用。同步把 `画布背景设置` 调整为 Lovart 式紧凑色板弹层。验证命令:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录弹出 `账号入口`,关闭后点击 `画布背景色` 显示色域、色相条、圆形预设和 HEX 输入,点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 对话框,`AI画布工具栏` 保持可见。
- 2026-06-17 前端拆分第十阶段:新增 `useImageCanvasAssetLibrary`,把账号级素材库加载、文件夹新建 / 折叠 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和素材库 401 登录弹窗从主视图抽出;主视图继续保留上传读取、上传进度、拖到画布坐标、画布图层创建和工程资源持久化。新增 hook 单测覆盖素材库归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后生成占位和 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见;登录临时开发账号后上传图片成功进入 `项目素材`,点击素材加入画布,切换 `图层` 可看到对应图层,控制台无前端 error。
@@ -92,14 +92,21 @@
- 该 hook 只处理 `generate/spec/character/icon` 这类画布生成对象,继续保留 `GenerateDialogState.mode === 'edit'` 的直接 `setGenerateDialog` 能力用于低风险迁移;真实生成请求、quick edit、角色动画、结果落图和资源持久化仍留在主视图。
- 主视图通过 `onActivate` 处理激活生成对象后的清空图层选择和关闭图片菜单等跨状态副作用,避免 dialog hook 反向依赖画布图层状态。
## 第十阶段模块
- `useImageCanvasAssetLibrary.ts`
- 承载账号级素材库状态模型:素材文件夹、素材列表、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和鉴权失败登录弹窗。
- 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。
- 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。
## 后续阶段
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
- 上传状态模型:上传占位卡片、读取图片、登录后续传、素材库和拖到画布创建图层仍在主视图与侧栏之间协作,后续需要等上传错误恢复规则进一步稳定后再收口。
## 验证计划
- `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`
- `npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `git diff --check`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,392 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { useRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const defaultOpenEditorLoginModal = () => {};
const defaultOnDeleteAssets = () => {};
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
createEditorAssetFolder: createEditorAssetFolderMock,
deleteEditorAsset: deleteEditorAssetMock,
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
updateEditorAsset: updateEditorAssetMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
};
});
function createUploadedAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-a',
label: '素材A',
src: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
...overrides,
};
}
function createAssetSnapshot(
overrides: Partial<EditorAssetSnapshot> = {},
): EditorAssetSnapshot {
return {
assetId: 'asset-a',
label: '素材A',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
folderId: 'project',
sourceType: 'uploaded',
...overrides,
};
}
function AssetLibraryHarness({
openEditorLoginModal = defaultOpenEditorLoginModal,
onDeleteAssets = defaultOnDeleteAssets,
}: {
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => void;
}) {
const assetListRef = useRef<HTMLDivElement | null>(null);
const assetLibrary = useImageCanvasAssetLibrary({
assetListRef,
openEditorLoginModal,
onDeleteAssets,
});
return (
<div>
<div ref={assetListRef}>
{assetLibrary.groupedAssets.map((folder) => (
<section key={folder.id} data-asset-folder-id={folder.id}>
<h2>{folder.label}</h2>
{folder.assets.map((asset, index) => (
<button
key={asset.id || `asset-${index}`}
type="button"
data-asset-id={asset.id}
onClick={() => assetLibrary.toggleAssetSelected(asset.id)}
>
{asset.label}
</button>
))}
</section>
))}
</div>
<span data-testid="folders">
{assetLibrary.assetFolders
.map((folder) => `${folder.id}:${folder.label}:${folder.persisted}`)
.join('|')}
</span>
<span data-testid="assets">
{assetLibrary.assets
.map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`)
.join('|')}
</span>
<span data-testid="active-upload-folder">
{assetLibrary.activeUploadFolderId}
</span>
<span data-testid="all-selected">
{String(assetLibrary.allSelectableAssetsSelected)}
</span>
<span data-testid="selected-assets">
{[...assetLibrary.selectedAssetIds].join('|')}
</span>
<span data-testid="new-folder-name">{assetLibrary.newFolderName}</span>
<button
type="button"
onClick={() => {
assetLibrary.setNewFolderName('角色素材');
assetLibrary.setCreatingFolder(true);
}}
>
prepare folder
</button>
<button
type="button"
onClick={() => void assetLibrary.commitNewAssetFolder()}
>
commit folder
</button>
<button
type="button"
onClick={() => assetLibrary.moveAssetToFolder('asset-a', 'folder-role')}
>
move asset
</button>
<button
type="button"
onClick={() => {
const target = assetLibrary.assets.find(
(asset) => asset.id === 'asset-a',
);
if (target) {
assetLibrary.deleteUploadedAsset(target);
}
}}
>
delete asset
</button>
<button type="button" onClick={assetLibrary.toggleAllAssetsSelected}>
toggle all
</button>
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
delete selected
</button>
</div>
);
}
describe('useImageCanvasAssetLibrary', () => {
beforeEach(() => {
vi.clearAllMocks();
loadEditorAssetLibraryMock.mockResolvedValue({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [createAssetSnapshot()],
});
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role',
label: '角色素材',
collapsed: false,
systemDefault: false,
});
updateEditorAssetMock.mockResolvedValue(createAssetSnapshot());
deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot());
deleteEditorAssetFolderMock.mockResolvedValue({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
updateEditorAssetFolderMock.mockResolvedValue({
folderId: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
});
});
it('loads and normalizes the account asset library', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'project-duplicate',
label: '重复默认',
sortOrder: 1,
collapsed: false,
systemDefault: true,
},
],
assets: [createAssetSnapshot()],
});
render(<AssetLibraryHarness />);
await waitFor(() => {
expect(screen.getByTestId('folders').textContent).toBe(
'project:项目素材:true',
);
});
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project',
);
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
'project',
);
});
it('opens login when loading the asset library is unauthorized', async () => {
const openEditorLoginModal = vi.fn();
loadEditorAssetLibraryMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />,
);
await waitFor(() => {
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
});
});
it('creates a local folder and replaces it with the persisted folder id', async () => {
let resolveCreateFolder: (
folder: Awaited<ReturnType<typeof createEditorAssetFolderMock>>,
) => void = () => {};
createEditorAssetFolderMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveCreateFolder = resolve;
}),
);
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'prepare folder' }).click());
await waitFor(() => {
expect(screen.getByTestId('new-folder-name').textContent).toBe(
'角色素材',
);
});
act(() => screen.getByRole('button', { name: 'commit folder' }).click());
await waitFor(() => {
expect(screen.getByTestId('folders').textContent).toContain(
'folder-',
);
});
act(() => {
resolveCreateFolder({
folderId: 'folder-role',
label: '角色素材',
collapsed: false,
systemDefault: false,
});
});
await waitFor(() => {
expect(screen.getByTestId('folders').textContent).toContain(
'folder-role:角色素材:true',
);
});
expect(createEditorAssetFolderMock).toHaveBeenCalledWith('角色素材', 101);
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
'folder-role',
);
});
it('moves a persisted asset to another folder', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色素材',
sortOrder: 1,
collapsed: false,
systemDefault: false,
},
],
assets: [createAssetSnapshot()],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'move asset' }).click());
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:folder-role',
);
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', {
folderId: 'folder-role',
});
});
it('deletes uploaded assets and reports them to the canvas cleanup callback', async () => {
const onDeleteAssets = vi.fn();
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
expect(screen.getByTestId('assets').textContent).toBe('');
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
]);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
});
it('selects and deletes selected uploaded assets', async () => {
const onDeleteAssets = vi.fn();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }),
createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }),
],
});
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'toggle all' }).click());
expect(screen.getByTestId('all-selected').textContent).toBe('true');
act(() => screen.getByRole('button', { name: '素材B' }).click());
await waitFor(() => {
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
});
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
expect(screen.getByTestId('assets').textContent).toBe(
'asset-b:素材B:project',
);
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
]);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
});
});
File diff suppressed because it is too large Load Diff