完善图片画布素材与项目封面交互

新增画布素材导出能力并补充 JSZip 依赖

优化素材上传占位进度、拖拽添加和文件夹移动交互

接入未登录项目访问弹窗并完善项目卡片封面缩略图

补充图片画布与项目页回归测试
This commit is contained in:
2026-06-16 16:15:15 +08:00
parent 80a382b034
commit 1d570605af
8 changed files with 2754 additions and 206 deletions
+170 -10
View File
@@ -12,6 +12,7 @@
"@vitejs/plugin-react": "^5.0.4",
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",
@@ -2749,6 +2750,12 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3925,6 +3932,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3964,8 +3977,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
@@ -4029,6 +4041,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -4203,6 +4221,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4225,6 +4285,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -4886,6 +4955,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5114,6 +5189,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -5463,6 +5544,12 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5996,9 +6083,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
@@ -9503,6 +9588,11 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10331,6 +10421,11 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -10360,8 +10455,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
"version": "1.3.8",
@@ -10408,6 +10502,11 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -10536,6 +10635,46 @@
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"dev": true
},
"jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"requires": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
},
"dependencies": {
"readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10555,6 +10694,14 @@
"type-check": "~0.4.0"
}
},
"lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"requires": {
"immediate": "~3.0.5"
}
},
"lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -10944,6 +11091,11 @@
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11099,6 +11251,11 @@
}
}
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -11334,6 +11491,11 @@
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -11701,9 +11863,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"optional": true
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"v8-to-istanbul": {
"version": "9.3.0",
+1
View File
@@ -72,6 +72,7 @@
"@vitejs/plugin-react": "^5.0.4",
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",
@@ -69,9 +69,11 @@ export type SidebarMediaItemProps = {
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
previewOverlay?: ReactNode;
footerNode?: ReactNode;
draggable?: boolean;
onDragStart?: DragEventHandler<HTMLDivElement>;
onDragEnd?: DragEventHandler<HTMLDivElement>;
onDragStart?: DragEventHandler<HTMLElement>;
onDragEnd?: DragEventHandler<HTMLElement>;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
onPointerDown?: PointerEventHandler<HTMLDivElement>;
@@ -93,6 +95,8 @@ export function SidebarMediaItem({
primaryClassName,
actions,
titleNode,
previewOverlay,
footerNode,
draggable,
onDragStart,
onDragEnd,
@@ -119,6 +123,9 @@ export function SidebarMediaItem({
className={primaryClassName}
onClick={onPrimaryClick}
aria-label={primaryLabel}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<PlatformMediaFrame
src={imageSrc}
@@ -127,11 +134,13 @@ export function SidebarMediaItem({
aspect="square"
surface="none"
className={thumbnailClassName}
previewOverlay={previewOverlay}
/>
</button>
<div className={metaClassName}>
{titleNode ?? <span>{title}</span>}
<span>{detail}</span>
{footerNode}
</div>
{actions}
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,11 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ProjectGalleryView } from './ProjectGalleryView';
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
@@ -11,6 +14,29 @@ const createEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
return {
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn((action: () => void) => action()),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(),
musicVolume: 0.5,
setMusicVolume: vi.fn(),
platformTheme: 'light' as const,
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
...overrides,
};
}
vi.mock('../../services/image-editor/editorProjectClient', () => ({
listEditorProjects: listEditorProjectsMock,
createEditorProject: createEditorProjectMock,
@@ -67,6 +93,115 @@ describe('ProjectGalleryView', () => {
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('uses canvas-center layer composition as the project cover', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
projectId: 'editor-project-cover',
title: '封面项目',
viewport: { x: 120, y: -60, scale: 0.8 },
layers: [
{
layerId: 'layer-cover-back',
resourceId: 'resource-back',
title: '背景图',
x: 200,
y: 160,
width: 300,
height: 180,
zIndex: 1,
},
{
layerId: 'layer-cover-front',
resourceId: 'resource-front',
title: '前景图',
x: 420,
y: 260,
width: 160,
height: 120,
zIndex: 2,
},
],
resources: [
{
resourceId: 'resource-front',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,front',
width: 160,
height: 120,
sourceType: 'uploaded',
},
{
resourceId: 'resource-back',
projectId: 'editor-project-cover',
imageSrc: 'data:image/png;base64,back',
width: 300,
height: 180,
sourceType: 'uploaded',
},
],
updatedAt: '2026-06-12T08:00:00.000Z',
},
]);
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
await screen.findByText('封面项目');
const cover = document.querySelector('.project-gallery__canvas-cover');
const coverLayers = document.querySelectorAll(
'.project-gallery__canvas-cover-layer',
);
expect(cover).toBeTruthy();
expect(coverLayers).toHaveLength(2);
expect(
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
).toBe('data:image/png;base64,back');
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
});
it('opens the login modal when project list loading is unauthorized', async () => {
const openLoginModal = vi.fn();
listEditorProjectsMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
<ProjectGalleryView onOpenProject={vi.fn()} />
</AuthUiContext.Provider>,
);
await waitFor(() => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
expect(screen.queryByRole('alert')).toBeNull();
});
it('requires login before opening a project card while logged out', async () => {
const onOpenProject = vi.fn();
const requireAuth = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
render(
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
<ProjectGalleryView onOpenProject={onOpenProject} />
</AuthUiContext.Provider>,
);
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
expect(onOpenProject).not.toHaveBeenCalled();
});
it('renders project loading errors through the shared status message', async () => {
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));
+128 -13
View File
@@ -14,8 +14,13 @@ import {
deleteEditorProject,
listEditorProjects,
renameEditorProject,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
@@ -29,6 +34,9 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
};
@@ -38,16 +46,110 @@ type RenameDraft = {
title: string;
};
function resolveProjectPreview(project: EditorProjectSnapshot) {
const layerResourceIds = new Set(
project.layers
.map((layer) => layer.resourceId)
.filter((resourceId) => resourceId.trim().length > 0),
function isUnauthorizedError(error: unknown) {
return error instanceof ApiClientError && error.status === 401;
}
type ProjectCoverLayer = {
layerId: string;
title: string;
imageSrc: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
};
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
const value = layer[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
const value = layer[key];
return typeof value === 'string' ? value.trim() : '';
}
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
return layer.hidden === true;
}
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
project.resources.map((resource) => [resource.resourceId, resource]),
);
return project.layers
.filter((layer) => !isLayerHidden(layer))
.map((layer) => {
const resource = resourcesById.get(layer.resourceId);
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
if (!imageSrc) {
return null;
}
return {
layerId: layer.layerId,
title: stringFromLayer(layer, 'title') || '画布图片',
imageSrc,
x: numberFromLayer(layer, 'x', 0),
y: numberFromLayer(layer, 'y', 0),
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
zIndex: numberFromLayer(layer, 'zIndex', 0),
} satisfies ProjectCoverLayer;
})
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
.sort((left, right) => left.zIndex - right.zIndex);
}
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
const coverLayers = resolveProjectCoverLayers(project);
if (!coverLayers.length) {
return <span className="project-gallery__preview-empty" />;
}
const safeScale =
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
? project.viewport.scale
: 1;
const viewportCenterX =
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
const viewportCenterY =
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
const scale = Math.min(
PROJECT_COVER_SIZE.width / worldPreviewWidth,
PROJECT_COVER_SIZE.height / worldPreviewHeight,
);
const offsetX = -previewMinX * scale;
const offsetY = -previewMinY * scale;
return (
project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ??
project.resources[0] ??
null
<span className="project-gallery__canvas-cover" aria-hidden="true">
{coverLayers.map((layer) => (
<span
key={layer.layerId}
className="project-gallery__canvas-cover-layer"
style={{
left: offsetX + layer.x * scale,
top: offsetY + layer.y * scale,
width: layer.width * scale,
height: layer.height * scale,
zIndex: layer.zIndex,
}}
>
<ResolvedAssetImage
src={layer.imageSrc}
alt=""
className="project-gallery__canvas-cover-image"
/>
</span>
))}
</span>
);
}
@@ -65,6 +167,7 @@ function formatProjectUpdatedAt(value: string) {
}
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const authUi = useAuthUi();
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -94,18 +197,26 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
});
})
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(refreshProjects);
return;
}
setErrorMessage(
error instanceof Error ? error.message : '读取项目列表失败',
);
})
.finally(() => setIsLoading(false));
}, []);
}, [authUi]);
useEffect(() => {
refreshProjects();
}, [refreshProjects]);
const createProject = useCallback(() => {
if (authUi && !authUi.user) {
authUi.openLoginModal(createProject);
return;
}
setErrorMessage(null);
createEditorProject()
.then((project) => onOpenProject(project.projectId))
@@ -114,7 +225,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
error instanceof Error ? error.message : '创建项目失败',
);
});
}, [onOpenProject]);
}, [authUi, onOpenProject]);
const closeSelectionMode = useCallback(() => {
setIsSelectionMode(false);
@@ -191,7 +302,6 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
const projectCards = useMemo(
() =>
projects.map((project) => {
const preview = resolveProjectPreview(project);
const selected = selectedProjectIds.has(project.projectId);
return (
<article
@@ -211,18 +321,22 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
toggleProjectSelection(project.projectId);
return;
}
if (authUi) {
authUi.requireAuth(() => onOpenProject(project.projectId));
return;
}
onOpenProject(project.projectId);
}}
aria-label={`打开项目${project.title}`}
>
<PlatformMediaFrame
src={preview?.imageSrc}
src={null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
className="project-gallery__preview"
fallbackContent={<span className="project-gallery__preview-empty" />}
fallbackContent={<ProjectCanvasCover project={project} />}
>
{isSelectionMode ? (
<span className="project-gallery__checkbox">
@@ -279,6 +393,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
}),
[
activeMenuProjectId,
authUi,
deleteProjects,
isSelectionMode,
onOpenProject,
+409 -14
View File
@@ -3071,6 +3071,40 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
border-radius: 0;
}
.project-gallery__preview .platform-media-frame {
border-radius: 0;
}
.project-gallery__canvas-cover {
position: absolute;
inset: 0;
overflow: hidden;
background: #f8fafc;
}
.project-gallery__canvas-cover::before {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent 46%),
radial-gradient(circle at 50% 44%, rgba(37, 99, 235, 0.08), transparent 58%);
content: '';
}
.project-gallery__canvas-cover-layer {
position: absolute;
display: block;
overflow: hidden;
border-radius: 0.18rem;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.14);
}
.project-gallery__canvas-cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-gallery__preview-empty {
width: 36%;
aspect-ratio: 1;
@@ -3388,9 +3422,23 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
align-items: center;
min-width: 0;
gap: 0.35rem;
border: 1px solid transparent;
border-radius: 0.45rem;
padding: 0.12rem 0.18rem;
color: #475569;
font-size: 0.76rem;
font-weight: 850;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
.image-canvas-editor__asset-folder--move-target
> .image-canvas-editor__asset-folder-header {
border-color: #93c5fd;
background: #eff6ff;
color: #1d4ed8;
}
.image-canvas-editor__asset-folder-header > span:first-of-type,
@@ -3446,6 +3494,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
gap: 0.5rem;
}
.image-canvas-editor__asset-folder-sticky-target {
position: sticky;
top: 0;
z-index: 8;
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.4rem;
border: 1px solid #93c5fd;
border-radius: 0.5rem;
background: #eff6ff;
box-shadow: 0 8px 18px rgb(15 23 42 / 0.1);
color: #1d4ed8;
font-size: 0.76rem;
font-weight: 850;
padding: 0.38rem 0.5rem;
}
.image-canvas-editor__asset-folder-sticky-target span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__asset-row {
display: grid;
grid-template-columns: 4.4rem minmax(0, 1fr) auto;
@@ -3469,6 +3542,41 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: #eef5ff;
}
.image-canvas-editor__asset-row--uploading {
border-color: #bfdbfe;
background: #f8fbff;
}
.image-canvas-editor__asset-row--uploading:hover {
transform: none;
border-color: #93c5fd;
background: #f8fbff;
}
.image-canvas-editor__asset-row--upload-failed {
border-color: #fecaca;
background: #fff7f7;
}
.image-canvas-editor__asset-drag-preview {
position: fixed;
z-index: 80;
max-width: 11rem;
pointer-events: none;
transform: translate(0.75rem, 0.75rem);
border: 1px solid #93c5fd;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 12px 28px rgb(15 23 42 / 0.16);
color: #1d4ed8;
font-size: 0.76rem;
font-weight: 850;
overflow: hidden;
padding: 0.42rem 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__asset-row--selected {
border-color: #2563eb;
background: #dbeafe;
@@ -3566,6 +3674,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background-size: 1rem 1rem;
}
.image-canvas-editor__asset-upload-overlay {
display: grid;
height: 100%;
place-items: center;
background: rgb(248 250 252 / 0.72);
color: #2563eb;
font-size: 0.72rem;
font-weight: 850;
}
.image-canvas-editor__layer img {
width: 100%;
height: 100%;
@@ -3586,6 +3704,56 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: nowrap;
}
.image-canvas-editor__asset-upload-status {
display: grid;
min-width: 2.4rem;
justify-items: end;
color: #2563eb;
font-size: 0.72rem;
font-weight: 800;
}
.image-canvas-editor__asset-upload-status strong {
font-size: 0.78rem;
}
.image-canvas-editor__asset-upload-progress {
display: grid;
gap: 0.22rem;
color: #2563eb;
font-size: 0.72rem;
font-weight: 760;
}
.image-canvas-editor__asset-upload-progress > div {
display: flex;
justify-content: space-between;
gap: 0.45rem;
}
.image-canvas-editor__asset-upload-progress progress {
overflow: hidden;
width: 100%;
height: 0.34rem;
border: 0;
border-radius: 999px;
background: #dbeafe;
}
.image-canvas-editor__asset-upload-progress progress::-webkit-progress-bar {
background: #dbeafe;
}
.image-canvas-editor__asset-upload-progress progress::-webkit-progress-value {
border-radius: 999px;
background: #2563eb;
}
.image-canvas-editor__asset-upload-progress progress::-moz-progress-bar {
border-radius: 999px;
background: #2563eb;
}
.image-canvas-editor__asset-rename-input {
min-width: 0;
height: 1.8rem;
@@ -3741,7 +3909,97 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
gap: 0.45rem;
}
.image-canvas-editor__title-block {
display: grid;
min-width: 0;
align-items: center;
row-gap: 0.04rem;
}
.image-canvas-editor__project-title-row {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.25rem;
}
.image-canvas-editor__project-title-button {
min-width: 0;
max-width: min(24rem, 42vw);
border: 0;
background: transparent;
color: inherit;
padding: 0;
text-align: left;
cursor: text;
}
.image-canvas-editor__project-title-button h1 {
max-width: 100%;
}
.image-canvas-editor__project-rename-button {
width: 1.65rem;
height: 1.65rem;
flex-shrink: 0;
border-color: transparent;
background: transparent;
color: #64748b;
opacity: 0;
transform: none;
}
.image-canvas-editor__project-title-row:hover .image-canvas-editor__project-rename-button,
.image-canvas-editor__project-rename-button:focus-visible {
opacity: 1;
}
.image-canvas-editor__project-rename-button:hover {
transform: none;
}
.image-canvas-editor__project-title-form {
display: inline-flex;
min-width: 0;
max-width: min(34rem, 58vw);
align-items: center;
gap: 0.35rem;
}
.image-canvas-editor__project-title-input {
width: clamp(9rem, 28vw, 18rem);
}
.image-canvas-editor__project-title-error {
max-width: 10rem;
overflow: hidden;
color: #dc2626;
font-size: 0.72rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__title-block h1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__topbar-actions {
display: inline-flex;
min-width: 0;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
}
.image-canvas-editor__topbar-actions .platform-status-message {
max-width: 12rem;
}
.image-canvas-editor__project-back-button {
@@ -4090,28 +4348,165 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
height: 0.9rem;
}
.image-canvas-editor__background-menu {
display: inline-flex;
gap: 0.25rem;
padding: 0.32rem;
.image-canvas-editor__background-panel {
position: absolute;
left: 0;
bottom: calc(100% + 0.65rem);
z-index: 35;
display: grid;
width: min(15.5rem, calc(100vw - 1.5rem));
box-sizing: border-box;
gap: 0.45rem;
padding: 0 0.65rem 0.65rem;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 0.9rem;
background: #ffffff;
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.16);
color: #111827;
overflow: hidden;
}
.image-canvas-editor__background-menu-item {
.image-canvas-editor__background-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 2.75rem;
margin: 0 -0.65rem 0;
padding: 0 0.65rem;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
color: #111827;
font-size: 0.8rem;
font-weight: 760;
}
.image-canvas-editor__background-close-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border: 0;
border-radius: 999px;
background: transparent;
color: #111827;
cursor: pointer;
}
.image-canvas-editor__background-close-button:hover,
.image-canvas-editor__background-close-button:focus-visible {
background: #f1f5f9;
outline: none;
}
.image-canvas-editor__background-color-field {
position: relative;
height: 8rem;
border-radius: 0.45rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0), #000000),
linear-gradient(90deg, #ffffff, #ff0000);
overflow: hidden;
}
.image-canvas-editor__background-color-field input[type='color'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
opacity: 0.001;
cursor: crosshair;
}
.image-canvas-editor__background-color-field::after {
position: absolute;
left: 0.3rem;
top: 0.3rem;
width: 0.72rem;
height: 0.72rem;
border: 2px solid #ffffff;
border-radius: 999px;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.18);
content: '';
pointer-events: none;
}
.image-canvas-editor__background-hue-track {
height: 0.66rem;
border-radius: 999px;
background: linear-gradient(
90deg,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
}
.image-canvas-editor__background-preset-grid {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
overflow: visible;
}
.image-canvas-editor__background-preset {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.4rem;
justify-content: center;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 999px;
background: #ffffff;
padding: 0;
cursor: pointer;
}
.image-canvas-editor__background-menu-item[aria-pressed='true'] {
border-color: #38bdf8;
background: #e0f2fe;
.image-canvas-editor__background-preset[aria-pressed='true'] {
border-color: #0ea5e9;
box-shadow: 0 0 0 2px #0ea5e9;
}
.image-canvas-editor__background-swatch {
width: 1rem;
height: 1rem;
.image-canvas-editor__background-preset span {
width: 100%;
height: 100%;
border-radius: inherit;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.06);
}
.image-canvas-editor__background-hex-row {
display: flex;
align-items: center;
gap: 0.35rem;
height: 1.75rem;
border-radius: 0.45rem;
background: #f3f4f6;
padding: 0 0.65rem;
color: #6b7280;
font-size: 0.78rem;
font-weight: 720;
}
.image-canvas-editor__background-hex-row input {
width: 100%;
border: 0;
background: transparent;
color: #374151;
font-size: 0.8rem;
font-weight: 760;
outline: none;
}
.image-canvas-editor__background-preset:hover,
.image-canvas-editor__background-preset:focus-visible {
border-color: #0ea5e9;
outline: none;
}
.image-canvas-editor__panel-dock button:hover,