From ac3befdadf0cc2f9427ec8ef84d480aa63bb51fa Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 23:19:02 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E9=99=90=E5=88=B6=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E7=B2=BE=E9=80=89=E7=B4=A0=E6=9D=90=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只从项目内画布生成资源构建陶泥儿精选 移除公开作品和账号素材库精选回退 补充精选数据边界测试和项目记忆文档 --- .../shared-memory/decision-log.md | 8 + ...作】创作主页与项目入口改版计划-2026-06-18.md | 19 +- .../CreationLandingView.test.tsx | 293 ++++++++---------- .../creation-home/CreationLandingView.tsx | 71 ++--- .../creationShowcaseModel.test.ts | 179 ++++++----- .../creation-home/creationShowcaseModel.ts | 177 +++++------ .../PlatformEntryFlowShellImpl.tsx | 4 - 7 files changed, 341 insertions(+), 410 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 863bcb53b..1dba0f6e6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-22 创作主页精选只展示项目内画布生成资源 + +- 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。 +- 决策:`陶泥儿精选` 只从用户图片画布项目中的 `editor_project_resource` 构建素材包和素材,且只保留 `sourceType="generated"` 的画布生成资源;账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。 +- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、最近项目列表资源读取、创作主页改版计划和精选素材相关测试。 +- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts` 与 `CreationLandingView.test.tsx`,确认项目内生成资源展示、上传素材过滤、公开作品不再 fallback。 +- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-06-22 编辑器生成模型默认定价调整 - 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 363ac3602..e1ab45503 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -15,7 +15,7 @@ - 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 - 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。 - 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 -- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。 +- `陶泥儿精选` 只展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 - 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 ## 页面结构 @@ -61,9 +61,9 @@ ### 陶泥儿精选 -`陶泥儿精选` 是页面底部的用户素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 +`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容优先使用真实账号级素材和生成结果数据;未登录或账号素材为空时,可使用已公开作品中真实用户生成的图片作为瀑布流素材来源。两种来源都不使用 mock。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 的项目资源,代表用户通过图片画布生成并已经落入某个项目的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 Tab: @@ -100,9 +100,10 @@ Tab: 数据来源: -- 图片类素材优先读取账号级素材库和已落库生成结果。 -- 未登录或账号素材为空时,读取已公开作品中的真实用户生成图片作为精选素材补充,但卡片仍按素材瀑布流呈现,不展示玩法入口卡。 -- 音乐、音效、视频类素材只展示已存在的真实记录。 +- 读取用户项目列表中的 `editor_project_resource` 快照,只保留 `sourceType="generated"` 的画布生成资源。 +- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。 +- 未登录、项目为空或项目内没有画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 +- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 - 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。 - 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。 @@ -113,7 +114,7 @@ Tab: - 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。 - 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。 - 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。 -- `陶泥儿精选` 读取账号级素材库;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。 +- `陶泥儿精选` 读取项目内画布生成资源;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。 - `/creation/` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。 ## 路由与导航 @@ -196,9 +197,9 @@ git diff --check - 移动视口底部不显示“创作”和“项目”。 - 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`。 - “我的”页没有项目快捷入口。 -- 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。 +- 陶泥儿精选素材瀑布流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。 - 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。 -- 陶泥儿精选不出现玩法入口卡;即使使用公开作品图片补充展示,也必须呈现为用户素材瀑布流。 +- 陶泥儿精选不出现玩法入口卡或公开作品图片补充展示。 ## 非目标 diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index ce026690d..31997e2d1 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -6,12 +6,10 @@ import type { ContextType } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AuthUiContext } from '../auth/AuthUiContext'; -import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import { CreationLandingView } from './CreationLandingView'; const listEditorProjectsMock = vi.hoisted(() => vi.fn()); const createEditorProjectMock = vi.hoisted(() => vi.fn()); -const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); type AuthValue = NonNullable>; @@ -48,7 +46,6 @@ function createAuthValue(overrides: Partial = {}): AuthValue { vi.mock('../../services/image-editor/editorProjectClient', () => ({ listEditorProjects: listEditorProjectsMock, createEditorProject: createEditorProjectMock, - loadEditorAssetLibrary: loadEditorAssetLibraryMock, })); const projectItems = [ @@ -72,12 +69,10 @@ const projectItems = [ type RenderCreationLandingOptions = { authValue?: AuthValue | null; - publicGalleryEntries?: PlatformPublicGalleryCard[]; }; function renderCreationLanding({ authValue = createAuthValue(), - publicGalleryEntries = [], }: RenderCreationLandingOptions = {}) { const result = render( @@ -85,7 +80,6 @@ function renderCreationLanding({ onOpenProject={vi.fn()} onOpenProjects={vi.fn()} onOpenCommunity={vi.fn()} - publicGalleryEntries={publicGalleryEntries} /> , ); @@ -97,12 +91,10 @@ describe('CreationLandingView', () => { afterEach(() => { listEditorProjectsMock.mockReset(); createEditorProjectMock.mockReset(); - loadEditorAssetLibraryMock.mockReset(); }); it('renders the Taonier creation landing modules without external branding', async () => { listEditorProjectsMock.mockResolvedValueOnce(projectItems); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] }); renderCreationLanding(); @@ -129,7 +121,6 @@ describe('CreationLandingView', () => { const onOpenProject = vi.fn(); const onOpenProjects = vi.fn(); listEditorProjectsMock.mockResolvedValueOnce(projectItems); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] }); render( @@ -173,7 +164,6 @@ describe('CreationLandingView', () => { const user = userEvent.setup(); const onOpenProject = vi.fn(); listEditorProjectsMock.mockResolvedValueOnce([]); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] }); createEditorProjectMock.mockResolvedValueOnce({ projectId: 'created-project', title: '未命名画布', @@ -203,7 +193,6 @@ describe('CreationLandingView', () => { const user = userEvent.setup(); const onOpenCommunity = vi.fn(); listEditorProjectsMock.mockResolvedValueOnce([]); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] }); render( @@ -221,30 +210,32 @@ describe('CreationLandingView', () => { expect(createEditorProjectMock).not.toHaveBeenCalled(); }); - it('renders real account assets in showcase tabs without mock rows', async () => { + it('renders generated project resources in showcase tabs without mock rows', async () => { const user = userEvent.setup(); - listEditorProjectsMock.mockResolvedValueOnce([]); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [], - assets: [ - { - assetId: 'asset-character', - folderId: 'folder-1', - label: '角色英雄', - imageSrc: 'data:image/png;base64,character', - width: 512, - height: 512, - sourceType: 'generated', - prompt: 'character hero prompt', - actualPrompt: null, - model: 'character-model', - provider: 'provider', - taskId: 'task-1', - authorName: '创作者A', - priceMudPoints: 20, - }, - ], - }); + listEditorProjectsMock.mockResolvedValueOnce([ + { + ...projectItems[0]!, + resources: [ + { + resourceId: 'resource-character', + projectId: 'project-newest', + label: '角色英雄', + imageSrc: 'data:image/png;base64,character', + width: 512, + height: 512, + sourceType: 'generated', + prompt: 'character hero prompt', + actualPrompt: null, + model: 'character-model', + provider: 'provider', + taskId: 'task-1', + assetKind: 'character', + authorName: '创作者A', + priceMudPoints: 20, + }, + ], + }, + ]); renderCreationLanding(); @@ -260,56 +251,57 @@ describe('CreationLandingView', () => { it('opens a fullscreen asset preview modal with thumbnail navigation', async () => { const user = userEvent.setup(); - listEditorProjectsMock.mockResolvedValueOnce([]); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [], - assets: [ - { - assetId: 'asset-character', - folderId: 'folder-1', - label: '角色英雄', - imageSrc: 'data:image/png;base64,character', - width: 512, - height: 512, - sourceType: 'generated', - prompt: 'character hero prompt', - actualPrompt: null, - model: 'character-model', - provider: 'provider', - taskId: 'task-1', - assetKind: 'character', - authorName: '创作者A', - priceMudPoints: 20, - }, - { - assetId: 'asset-animation', - folderId: 'folder-1', - label: '待机动画', - imageSrc: 'data:image/png;base64,animation', - width: 512, - height: 512, - sourceType: 'generated', - prompt: 'idle animation prompt', - actualPrompt: null, - model: 'seedance2.0-fast', - provider: 'ark', - taskId: 'task-2', - assetKind: 'character-animation', - priceMudPoints: 40, - generationInputs: { - fields: [{ title: '动作描述', value: '待机动作' }], - references: [ - { - title: '角色图片', - label: '角色英雄', - refType: 'asset', - refId: 'asset-character', - }, - ], + listEditorProjectsMock.mockResolvedValueOnce([ + { + ...projectItems[0]!, + resources: [ + { + resourceId: 'resource-character', + projectId: 'project-newest', + label: '角色英雄', + imageSrc: 'data:image/png;base64,character', + width: 512, + height: 512, + sourceType: 'generated', + prompt: 'character hero prompt', + actualPrompt: null, + model: 'character-model', + provider: 'provider', + taskId: 'task-1', + assetKind: 'character', + authorName: '创作者A', + priceMudPoints: 20, }, - }, - ], - }); + { + resourceId: 'resource-animation', + projectId: 'project-newest', + label: '待机动画', + imageSrc: 'data:image/png;base64,animation', + width: 512, + height: 512, + sourceType: 'generated', + prompt: 'idle animation prompt', + actualPrompt: null, + model: 'seedance2.0-fast', + provider: 'ark', + taskId: 'task-2', + assetKind: 'character-animation', + priceMudPoints: 40, + generationInputs: { + fields: [{ title: '动作描述', value: '待机动作' }], + references: [ + { + title: '角色图片', + label: '角色英雄', + refType: 'project-resource', + refId: 'resource-character', + }, + ], + }, + }, + ], + }, + ]); renderCreationLanding(); @@ -332,46 +324,67 @@ describe('CreationLandingView', () => { ).toBe('true'); }); - it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => { - listEditorProjectsMock.mockResolvedValueOnce([]); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [], - assets: [ - { - assetId: 'asset-pack-a', - folderId: 'folder-1', - label: '素材包 A', - imageSrc: 'data:image/png;base64,a', - width: 512, - height: 640, - sourceType: 'generated', - prompt: '素材包提示词', - actualPrompt: null, - model: null, - provider: null, - taskId: null, - }, - { - assetId: 'asset-pack-b', - folderId: 'folder-1', - label: '素材包 B', - imageSrc: 'data:image/png;base64,b', - width: 512, - height: 512, - sourceType: 'generated', - prompt: '第二组素材', - actualPrompt: null, - model: null, - provider: null, - taskId: null, - }, - ], - }); + it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => { + listEditorProjectsMock.mockResolvedValueOnce([ + { + ...projectItems[0]!, + resources: [ + { + resourceId: 'resource-pack-a', + projectId: 'project-newest', + label: '素材包 A', + imageSrc: 'data:image/png;base64,a', + width: 512, + height: 640, + sourceType: 'generated', + prompt: '素材包提示词', + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + { + resourceId: 'resource-uploaded', + projectId: 'project-newest', + label: '上传素材', + imageSrc: 'data:image/png;base64,uploaded', + width: 512, + height: 512, + sourceType: 'uploaded', + prompt: null, + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + ], + }, + { + ...projectItems[1]!, + resources: [ + { + resourceId: 'resource-pack-b', + projectId: 'project-old', + label: '素材包 B', + imageSrc: 'data:image/png;base64,b', + width: 512, + height: 512, + sourceType: 'generated', + prompt: '第二组素材', + actualPrompt: null, + model: null, + provider: null, + taskId: null, + }, + ], + }, + ]); const { container } = renderCreationLanding(); expect(await screen.findByText('素材包 A')).toBeTruthy(); expect(screen.getByText('素材包 B')).toBeTruthy(); + expect(screen.queryByText('上传素材')).toBeNull(); expect( screen.getByLabelText('用户素材瀑布流').className, ).toContain('creation-landing__asset-waterfall'); @@ -383,52 +396,16 @@ describe('CreationLandingView', () => { expect(screen.queryByText('拼图关卡创作')).toBeNull(); }); - it('falls back to real public user assets for the featured waterfall', async () => { - const publicAsset = { - sourceType: 'puzzle', - workId: 'work-public-asset', - profileId: 'profile-public-asset', - publicWorkCode: 'PZ-PUBLIC-ASSET', - ownerUserId: 'owner-public-asset', - authorDisplayName: '公开作者', - worldName: '公开素材组合', - subtitle: '公开素材副标题', - summaryText: '公开用户生成素材提示词', - coverImageSrc: 'https://assets.example.test/public-asset.png', - coverSlides: [ - { - id: 'slide-1', - imageSrc: 'https://assets.example.test/public-asset-slide.png', - label: '公开素材图', - }, - ], - themeTags: ['素材包'], - visibility: 'published', - publishedAt: '2026-06-18T09:00:00.000Z', - updatedAt: '2026-06-18T10:00:00.000Z', - } satisfies PlatformPublicGalleryCard; - - const { container } = renderCreationLanding({ + it('keeps the featured waterfall empty when project resources are empty', async () => { + renderCreationLanding({ authValue: createAuthValue({ user: null, canAccessProtectedData: false, }), - publicGalleryEntries: [publicAsset], }); - expect(await screen.findByText('公开素材组合')).toBeTruthy(); - const card = screen - .getByText('公开素材组合') - .closest('.creation-landing__asset-card'); - expect(card).toBeTruthy(); - expect( - within(card as HTMLElement).getByText('公开用户生成素材提示词'), - ).toBeTruthy(); - expect(within(card as HTMLElement).getByText('公开作者')).toBeTruthy(); - expect( - container.querySelector('.creation-landing__asset-waterfall'), - ).toBeTruthy(); - expect(screen.queryByText('暂无素材')).toBeNull(); + expect(await screen.findByText('暂无素材')).toBeTruthy(); + expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull(); expect(screen.queryByText('精选入口')).toBeNull(); }); }); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 23c79b9f0..981a31f7b 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -4,8 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { createEditorProject, listEditorProjects, - loadEditorAssetLibrary, - type EditorAssetLibrarySnapshot, + type EditorProjectResourceSnapshot, type EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { ApiClientError } from '../../services/apiClient'; @@ -20,7 +19,6 @@ import { UnifiedModal } from '../common/UnifiedModal'; import { ProjectCanvasCover } from '../project/ProjectCanvasCover'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetVideo } from '../ResolvedAssetVideo'; -import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import { buildCreationShowcaseItems, SHOWCASE_TABS, @@ -33,7 +31,6 @@ type CreationLandingViewProps = { onOpenProject: (projectId: string) => void; onOpenProjects: () => void; onOpenCommunity?: () => void; - publicGalleryEntries?: PlatformPublicGalleryCard[]; }; type CreationFeatureItem = { @@ -360,7 +357,6 @@ export function CreationLandingView({ onOpenProject, onOpenProjects, onOpenCommunity, - publicGalleryEntries = [], }: CreationLandingViewProps) { const authUi = useAuthUi(); const isAuthenticated = Boolean(authUi?.user); @@ -370,9 +366,9 @@ export function CreationLandingView({ const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false); const [projectError, setProjectError] = useState(null); - const [assetLibrary, setAssetLibrary] = - useState(null); - const [isLoadingAssets, setIsLoadingAssets] = useState(false); + const [projectShowcaseResources, setProjectShowcaseResources] = useState< + EditorProjectResourceSnapshot[] + >([]); const [assetError, setAssetError] = useState(null); const [activeShowcaseTab, setActiveShowcaseTab] = useState('packs'); @@ -383,21 +379,24 @@ export function CreationLandingView({ const refreshRecentProjects = useCallback(() => { if (!isAuthenticated) { setRecentProjects([]); + setProjectShowcaseResources([]); setProjectError(null); + setAssetError(null); return; } setIsLoadingProjects(true); setProjectError(null); + setAssetError(null); listEditorProjects() .then((projects) => { - setRecentProjects( - [...projects] - .sort( - (left, right) => - new Date(right.updatedAt).getTime() - - new Date(left.updatedAt).getTime(), - ) - .slice(0, 4), + const sortedProjects = [...projects].sort( + (left, right) => + new Date(right.updatedAt).getTime() - + new Date(left.updatedAt).getTime(), + ); + setRecentProjects(sortedProjects.slice(0, 4)); + setProjectShowcaseResources( + sortedProjects.flatMap((project) => project.resources ?? []), ); }) .catch((error: unknown) => { @@ -405,42 +404,20 @@ export function CreationLandingView({ authUi?.openLoginModal(refreshRecentProjects); return; } + const message = + error instanceof Error ? error.message : '读取最近项目失败'; setProjectError( - error instanceof Error ? error.message : '读取最近项目失败', + message, ); + setAssetError(message); }) .finally(() => setIsLoadingProjects(false)); }, [authUi, isAuthenticated]); - const refreshAssetLibrary = useCallback(() => { - if (!isAuthenticated) { - setAssetLibrary(null); - setAssetError(null); - return; - } - setIsLoadingAssets(true); - setAssetError(null); - loadEditorAssetLibrary() - .then(setAssetLibrary) - .catch((error: unknown) => { - if (isUnauthorizedError(error)) { - return; - } - setAssetError( - error instanceof Error ? error.message : '读取素材展示失败', - ); - }) - .finally(() => setIsLoadingAssets(false)); - }, [isAuthenticated]); - useEffect(() => { refreshRecentProjects(); }, [refreshRecentProjects]); - useEffect(() => { - refreshAssetLibrary(); - }, [refreshAssetLibrary]); - const createProject = useCallback(() => { if (authUi && !authUi.user) { authUi.openLoginModal(createProject); @@ -466,16 +443,14 @@ export function CreationLandingView({ () => buildCreationShowcaseItems({ activeTab: activeShowcaseTab, - assetLibrary, - publicGalleryEntries, + projectResources: projectShowcaseResources, fallbackAuthorName: authUi?.user?.displayName, }), - [activeShowcaseTab, assetLibrary, authUi?.user?.displayName, publicGalleryEntries], + [activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName], ); - const isShowcaseLoading = isAuthenticated && isLoadingAssets; - const showcaseEmptyText = - publicGalleryEntries.length > 0 ? '暂无匹配素材' : '暂无素材'; + const isShowcaseLoading = isAuthenticated && isLoadingProjects; + const showcaseEmptyText = '暂无素材'; const renderAssetShowcase = () => { if (assetError) { diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index 90ffd8089..30f6d8c96 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it } from 'vitest'; -import type { - EditorAssetLibrarySnapshot, - EditorAssetSnapshot, -} from '../../services/image-editor/editorProjectClient'; +import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient'; import { buildCreationShowcaseItems } from './creationShowcaseModel'; -function createAsset( - overrides: Partial & Record, -): EditorAssetSnapshot { +function createResource( + overrides: Partial & Record, +): EditorProjectResourceSnapshot { return { - assetId: 'asset-1', - folderId: 'folder-1', + resourceId: 'resource-1', + projectId: 'project-1', label: '素材', imageSrc: 'data:image/png;base64,asset', width: 512, @@ -23,31 +20,53 @@ function createAsset( provider: null, taskId: null, ...overrides, - }; -} - -function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot { - return { - folders: [], - assets, - }; + } as EditorProjectResourceSnapshot; } describe('creationShowcaseModel', () => { + it('only builds showcase items from generated project resources', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'packs', + projectResources: [ + createResource({ + resourceId: 'generated-1', + label: '画布生成素材', + sourceType: 'generated', + prompt: '真实画布生成', + }), + createResource({ + resourceId: 'uploaded-1', + label: '上传素材', + sourceType: 'uploaded', + prompt: '上传不应展示', + }), + createResource({ + resourceId: 'mock-1', + label: 'Mock 素材', + sourceType: 'mock_generated', + prompt: 'Mock 不应展示', + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('画布生成素材'); + }); + it('groups pack items by the same referenced spec image and sums known cost', () => { const items = buildCreationShowcaseItems({ activeTab: 'packs', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'spec-1', + projectResources: [ + createResource({ + resourceId: 'spec-1', label: '角色规范图', assetKind: 'spec', prompt: '规范提示词', priceMudPoints: 5, authorName: '创作者A', }), - createAsset({ - assetId: 'character-1', + createResource({ + resourceId: 'character-1', label: '角色主图', assetKind: 'character', priceMudPoints: 12, @@ -57,14 +76,13 @@ describe('creationShowcaseModel', () => { { title: '角色规范', label: '角色规范图', - refType: 'asset', + refType: 'project-resource', refId: 'spec-1', }, ], }, }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); @@ -81,15 +99,15 @@ describe('creationShowcaseModel', () => { it('groups assets when references point to the source project resource', () => { const items = buildCreationShowcaseItems({ activeTab: 'packs', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'spec-1', + projectResources: [ + createResource({ + resourceId: 'spec-1', label: '项目规范图', assetKind: 'spec', sourceResourceId: 'resource-spec-1', }), - createAsset({ - assetId: 'character-1', + createResource({ + resourceId: 'character-1', label: '项目角色图', assetKind: 'character', generationInputs: { @@ -104,8 +122,7 @@ describe('creationShowcaseModel', () => { ], }, }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); @@ -118,15 +135,15 @@ describe('creationShowcaseModel', () => { it('groups character animations under the referenced character image', () => { const items = buildCreationShowcaseItems({ activeTab: 'characters', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'character-1', + projectResources: [ + createResource({ + resourceId: 'character-1', label: '游戏角色图', assetKind: 'character', prompt: '角色提示词', }), - createAsset({ - assetId: 'animation-1', + createResource({ + resourceId: 'animation-1', label: '待机动画', assetKind: 'character-animation', imageSrc: 'data:image/png;base64,animation', @@ -136,14 +153,13 @@ describe('creationShowcaseModel', () => { { title: '角色图片', label: '游戏角色图', - refType: 'asset', + refType: 'project-resource', refId: 'character-1', }, ], }, }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); @@ -157,15 +173,15 @@ describe('creationShowcaseModel', () => { it('groups UI design extraction assets under the referenced UI image', () => { const items = buildCreationShowcaseItems({ activeTab: 'ui', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'ui-design-1', + projectResources: [ + createResource({ + resourceId: 'ui-design-1', label: '游戏UI图', assetKind: 'ui-design', prompt: 'UI提示词', }), - createAsset({ - assetId: 'icon-sheet-1', + createResource({ + resourceId: 'icon-sheet-1', label: 'UI子素材图集', assetKind: 'icon-spritesheet', generationInputs: { @@ -174,14 +190,14 @@ describe('creationShowcaseModel', () => { { title: 'UI设计图', label: '游戏UI图', - refType: 'asset', + refType: 'project-resource', refId: 'ui-design-1', }, ], }, }), - createAsset({ - assetId: 'icon-1', + createResource({ + resourceId: 'icon-1', label: '开始按钮', assetKind: 'icon', generationInputs: { @@ -190,14 +206,13 @@ describe('creationShowcaseModel', () => { { title: 'UI设计图', label: '游戏UI图', - refType: 'asset', + refType: 'project-resource', refId: 'ui-design-1', }, ], }, }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); @@ -212,16 +227,16 @@ describe('creationShowcaseModel', () => { it('counts a spritesheet task only once when UI children share the same task', () => { const items = buildCreationShowcaseItems({ activeTab: 'ui', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'ui-design-1', + projectResources: [ + createResource({ + resourceId: 'ui-design-1', label: '游戏UI图', assetKind: 'ui-design', model: 'gemini-3.1-flash-image-preview', taskId: 'task-ui-design', }), - createAsset({ - assetId: 'icon-sheet-1', + createResource({ + resourceId: 'icon-sheet-1', label: 'UI子素材图集', assetKind: 'icon-spritesheet', model: 'gpt-image-2', @@ -232,14 +247,14 @@ describe('creationShowcaseModel', () => { { title: 'UI设计图', label: '游戏UI图', - refType: 'asset', + refType: 'project-resource', refId: 'ui-design-1', }, ], }, }), - createAsset({ - assetId: 'icon-1', + createResource({ + resourceId: 'icon-1', label: '开始按钮', assetKind: 'icon', model: 'gpt-image-2', @@ -250,34 +265,32 @@ describe('creationShowcaseModel', () => { { title: 'UI设计图', label: '游戏UI图', - refType: 'asset', + refType: 'project-resource', refId: 'ui-design-1', }, ], }, }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); - expect(items[0]?.cost).toBe('32泥点'); + expect(items[0]?.cost).toBe('15泥点'); }); it('keeps music assets as audio preview items', () => { const items = buildCreationShowcaseItems({ activeTab: 'music', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'music-1', + projectResources: [ + createResource({ + resourceId: 'music-1', label: '背景音乐', imageSrc: '/generated-editor-audios/music.mp3', assetKind: 'background-music', prompt: '舒缓背景音乐', priceMudPoints: '5', }), - ]), - publicGalleryEntries: [], + ], fallbackAuthorName: '当前用户', }); @@ -290,21 +303,20 @@ describe('creationShowcaseModel', () => { it('keeps character animation videos out of the marketing tab', () => { const items = buildCreationShowcaseItems({ activeTab: 'marketing', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'animation-1', + projectResources: [ + createResource({ + resourceId: 'animation-1', label: '角色动画', assetKind: 'character-animation', imageSrc: '/generated-editor-videos/animation.mp4', }), - createAsset({ - assetId: 'poster-1', + createResource({ + resourceId: 'poster-1', label: '上线美宣视频', assetKind: 'publication-material', imageSrc: '/generated-editor-videos/poster.mp4', }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); @@ -315,9 +327,9 @@ describe('creationShowcaseModel', () => { it('infers costs from generated asset kinds when stored price is absent', () => { const items = buildCreationShowcaseItems({ activeTab: 'characters', - assetLibrary: createLibrary([ - createAsset({ - assetId: 'character-1', + projectResources: [ + createResource({ + resourceId: 'character-1', label: '游戏角色图', imageSrc: '/generated-editor-assets/character.png', objectKey: 'generated-editor-assets/character.png', @@ -325,8 +337,8 @@ describe('creationShowcaseModel', () => { model: 'gpt-image-2', prompt: '角色提示词', }), - createAsset({ - assetId: 'animation-1', + createResource({ + resourceId: 'animation-1', label: '待机动画', assetKind: 'character-animation', imageSrc: '/generated-editor-videos/animation.mp4', @@ -339,18 +351,17 @@ describe('creationShowcaseModel', () => { { title: '角色图片', label: '游戏角色图', - refType: 'asset', + refType: 'project-resource', refId: 'character-1', }, ], }, }), - ]), - publicGalleryEntries: [], + ], }); expect(items).toHaveLength(1); - expect(items[0]?.cost).toBe('140泥点'); + expect(items[0]?.cost).toBe('123泥点'); expect(items[0]?.previews[0]?.objectKey).toBe( 'generated-editor-assets/character.png', ); diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index 2e6f299c9..bd5fa4a69 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -1,7 +1,7 @@ import type { EditorAssetGenerationInputReference, - EditorAssetLibrarySnapshot, EditorAssetSnapshot, + EditorProjectResourceSnapshot, } from '../../services/image-editor/editorProjectClient'; import { calculateCharacterAnimationPrice, @@ -15,12 +15,6 @@ import { DEFAULT_VIDEO_DURATION_SECONDS, DEFAULT_VIDEO_MODEL, } from '../image-editor/ImageCanvasGenerationModel'; -import { - resolvePlatformWorkAuthorDisplayName, - resolvePlatformWorldCoverImage, - resolvePlatformWorldCoverSlides, - type PlatformPublicGalleryCard, -} from '../rpg-entry/rpgEntryWorldPresentation'; export type ShowcaseTabId = | 'packs' @@ -91,6 +85,10 @@ function assetRecord(asset: EditorAssetSnapshot) { return asset as Record; } +function projectResourceRecord(resource: EditorProjectResourceSnapshot) { + return resource as unknown as Record; +} + function normalizeText(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : ''; } @@ -162,6 +160,9 @@ function getSelfAssetKey(asset: EditorAssetSnapshot) { function getAssetReferenceKeys(asset: EditorAssetSnapshot) { const keys = [getSelfAssetKey(asset)]; + if (asset.assetId?.trim()) { + keys.push(`project-resource:${asset.assetId.trim()}`); + } if (asset.sourceResourceId?.trim()) { keys.push(`project-resource:${asset.sourceResourceId.trim()}`); } @@ -174,6 +175,61 @@ function getAssetReferenceKeys(asset: EditorAssetSnapshot) { return keys; } +function projectResourceToShowcaseAsset( + resource: EditorProjectResourceSnapshot, +): EditorAssetSnapshot { + const record = projectResourceRecord(resource); + const fallbackLabel = + normalizeText(record.label) || + normalizeText(record.title) || + resource.assetKind?.trim() || + '项目素材'; + + return { + ...record, + assetId: resource.resourceId, + folderId: `project:${resource.projectId}`, + label: fallbackLabel, + imageSrc: resource.imageSrc, + objectKey: resource.objectKey, + assetObjectId: resource.assetObjectId, + width: resource.width, + height: resource.height, + sourceType: resource.sourceType, + prompt: resource.prompt ?? null, + actualPrompt: resource.actualPrompt ?? null, + model: resource.model ?? null, + provider: resource.provider ?? null, + taskId: resource.taskId ?? null, + durationSeconds: resource.durationSeconds, + sourceResourceId: resource.sourceResourceId, + assetKind: resource.assetKind, + generationInputs: resource.generationInputs, + createdAt: resource.createdAt, + updatedAt: resource.updatedAt, + } as EditorAssetSnapshot; +} + +function buildGeneratedProjectResourceAssets( + resources: EditorProjectResourceSnapshot[], +) { + const seenResourceIds = new Set(); + + return resources + .filter( + (resource) => + resource.sourceType === 'generated' && resource.imageSrc.trim(), + ) + .filter((resource) => { + if (seenResourceIds.has(resource.resourceId)) { + return false; + } + seenResourceIds.add(resource.resourceId); + return true; + }) + .map(projectResourceToShowcaseAsset); +} + function findReference( asset: EditorAssetSnapshot, matcher: (reference: EditorAssetGenerationInputReference) => boolean, @@ -641,114 +697,21 @@ function toLibraryShowcaseItem( }; } -function matchesPublicGalleryTab( - entry: PlatformPublicGalleryCard, - tab: ShowcaseTabId, -) { - if (tab === 'packs') { - return true; - } - if (tab === 'games') { - return false; - } - - const themeTags = 'themeTags' in entry ? entry.themeTags : []; - const haystack = [ - entry.worldName, - entry.subtitle, - entry.summaryText, - entry.sourceType, - ...themeTags, - ] - .join(' ') - .toLowerCase(); - - if (tab === 'characters') { - return /角色|character|rpg|visual/u.test(haystack); - } - if (tab === 'ui') { - return /\bui\b|界面|图标|icon|puzzle/u.test(haystack); - } - if (tab === 'music') { - return /音乐|音效|audio|music|sound/u.test(haystack); - } - if (tab === 'marketing') { - return /美宣|宣传|poster|video|视觉|visual/u.test(haystack); - } - if (tab === 'effects') { - return /特效|effect|vfx/u.test(haystack); - } - if (tab === 'scenes') { - return /场景|scene|background|world|rpg/u.test(haystack); - } - return false; -} - -function toPublicGalleryShowcaseItems( - entries: PlatformPublicGalleryCard[], - tab: ShowcaseTabId, -): ShowcaseAssetItem[] { - const usedImageSrcs = new Set(); - const items: ShowcaseAssetItem[] = []; - - entries - .filter((entry) => matchesPublicGalleryTab(entry, tab)) - .forEach((entry) => { - const coverImageSrc = resolvePlatformWorldCoverImage(entry).trim(); - const imageCandidates = [ - ...resolvePlatformWorldCoverSlides(entry).map((slide) => ({ - src: slide.imageSrc.trim(), - label: slide.label?.trim() || entry.worldName, - })), - { src: coverImageSrc, label: entry.worldName }, - ].filter((candidate) => candidate.src); - - imageCandidates.forEach((candidate, index) => { - if (usedImageSrcs.has(candidate.src)) { - return; - } - usedImageSrcs.add(candidate.src); - items.push({ - id: `public:${entry.sourceType}:${entry.profileId}:${index}`, - label: index === 0 ? entry.worldName : `${entry.worldName} ${index + 1}`, - previews: [ - { - id: `public:${entry.profileId}:${index}`, - label: candidate.label, - src: candidate.src, - mediaType: 'image', - width: index === 0 ? 720 : 640, - height: index === 0 ? 480 : 720, - }, - ], - prompt: entry.summaryText || entry.subtitle || UNKNOWN_META_VALUE, - author: resolvePlatformWorkAuthorDisplayName(entry), - cost: UNKNOWN_META_VALUE, - }); - }); - }); - - return items.slice(0, 36); -} - export function buildCreationShowcaseItems({ activeTab, - assetLibrary, - publicGalleryEntries, + projectResources, fallbackAuthorName, }: { activeTab: ShowcaseTabId; - assetLibrary: EditorAssetLibrarySnapshot | null; - publicGalleryEntries: PlatformPublicGalleryCard[]; + projectResources: EditorProjectResourceSnapshot[]; fallbackAuthorName?: string | null; }) { - const libraryItems = getGroupsForTab(assetLibrary?.assets ?? [], activeTab) + const projectItems = getGroupsForTab( + buildGeneratedProjectResourceAssets(projectResources), + activeTab, + ) .map((group) => toLibraryShowcaseItem(group, fallbackAuthorName)) .filter((item): item is ShowcaseAssetItem => item !== null); - if (libraryItems.length > 0) { - return libraryItems.slice(0, 36); - } - - return toPublicGalleryShowcaseItems(publicGalleryEntries, activeTab); + return projectItems.slice(0, 36); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b62f82cb3..f678d91c3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -15283,10 +15283,6 @@ export function PlatformEntryFlowShellImpl({ onOpenProject={openEditorProject} onOpenProjects={openProjectGallery} onOpenCommunity={openCreationCommunity} - publicGalleryEntries={[ - ...featuredGalleryEntries, - ...latestGalleryEntries, - ]} /> ) : ( From 6c984a24f6687877ae1b21479852f9f0c5a756e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Tue, 23 Jun 2026 00:19:09 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=BB=9A=E8=BD=AE?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E5=92=8C=E5=9B=BE=E6=A0=87=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E6=AF=94=E4=BE=8Bbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将画布滚轮缩放下限调整为可见 5%。 修正图标素材生成占位框尺寸,避免 1:1 默认外框过大。 取消图标素材占位装饰 tile 的反向缩放,避免缩小时内部容器异常放大。 补充画布缩放、图标占位和文档验收说明。 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../ImageCanvasEditorModel.test.ts | 2 ++ .../image-editor/ImageCanvasEditorModel.ts | 2 +- .../ImageCanvasGenerationDialogModel.test.ts | 12 +++++----- .../ImageCanvasGenerationDialogModel.ts | 19 +++++++--------- .../ImageCanvasInteractionModel.test.ts | 19 ++++++++++++++-- .../ImageCanvasWorldView.test.tsx | 22 +++++++++++++++++++ .../image-editor/ImageCanvasWorldView.tsx | 2 +- 9 files changed, 60 insertions(+), 21 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 347df3aeb..03a941f2a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -112,7 +112,7 @@ - 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。 - 图层面板支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 - 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。 -- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。 +- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 - 工程刷新后能从后端恢复资源、图层布局和 viewport。 - “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=` 并按 query 加载该工程。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index ad2ae3fa2..a134012c5 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -10,6 +10,7 @@ - 底部 AI 画布工具栏新增 `生成图标素材` 按钮。 - 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。 +- 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。 - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 - 生成完成后删除占位态,先把后端返回的 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,再把拆分出的每个独立图标素材作为画布图片图层铺开,图集与图标之间、图标彼此之间不重叠,并保留少量间距。 - 图标素材图层写入 `assetKind: "icon"`;图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 2c1f980c4..0aff85bc9 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -27,9 +27,11 @@ describe('ImageCanvasEditorModel', () => { expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25); expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5); expect(canvasDisplayScaleToViewportScale(2)).toBe(1); + expect(canvasDisplayScaleToViewportScale(0.05)).toBe(0.025); expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5); expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1); expect(viewportScaleToCanvasDisplayScale(1)).toBe(2); + expect(formatCanvasDisplayScalePercent(0.025)).toBe('5%'); expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%'); expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%'); expect( diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index ea803eb85..75a033d9c 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -30,7 +30,7 @@ export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; -export const MIN_SCALE = 0.24; +export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const TOOLBAR_HALF_WIDTH = 132; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 5b2a8d711..2fe74381b 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -118,7 +118,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); - it('creates character and icon generation drafts with model dimensions', () => { + it('creates character generation drafts with model dimensions and icon drafts with icon frame dimensions', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; @@ -164,10 +164,12 @@ describe('ImageCanvasGenerationDialogModel', () => { '冻结按钮', ], placeholder: { - x: -32, - y: -152, - width: 1024, - height: 1024, + x: 300, + y: 180, + width: 360, + height: 360, + originalWidth: 512, + originalHeight: 512, }, }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index bef1636d4..5ee431dc6 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -31,6 +31,8 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, EDITOR_IMAGE_DIMENSION_OPTIONS, ICON_DESCRIPTION_LIMIT, + ICON_FRAME_DISPLAY_SIZE, + ICON_FRAME_ORIGINAL_SIZE, inferEditorImageAspectRatio, inferEditorImageSizeLabel, normalizeEditorImageModel, @@ -261,11 +263,6 @@ export function createIconGenerationDialogDraft({ const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); const normalizedImageModel = normalizeEditorImageModel(imageModel); const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel); - const placeholderSize = resolveEditorImageGenerationPixelSize({ - model: normalizedImageModel, - aspectRatio: dimensionDefaults.aspectRatio, - imageSize: dimensionDefaults.imageSize, - }); return { mode: 'icon', prompt: formatIconDescriptionsText(DEFAULT_ICON_DESCRIPTIONS), @@ -277,12 +274,12 @@ export function createIconGenerationDialogDraft({ aspectRatio: dimensionDefaults.aspectRatio, imageSize: dimensionDefaults.imageSize, placeholder: { - x: worldCenter.x - placeholderSize.width / 2, - y: worldCenter.y - placeholderSize.height / 2, - width: placeholderSize.width, - height: placeholderSize.height, - originalWidth: placeholderSize.width, - originalHeight: placeholderSize.height, + x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2, + width: ICON_FRAME_DISPLAY_SIZE.width, + height: ICON_FRAME_DISPLAY_SIZE.height, + originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, + originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, }, }; } diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index d461ca4cc..7356be03f 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -84,10 +84,10 @@ describe('ImageCanvasInteractionModel', () => { expect( fitViewportToLayers({ - layers: [createLayer({ width: 5000, height: 3000 })], + layers: [createLayer({ width: 50000, height: 30000 })], canvasSize: { width: 900, height: 640 }, })?.scale, - ).toBe(0.24); + ).toBe(0.025); }); it('maps client points to canvas and world coordinates with safe fallbacks', () => { @@ -142,6 +142,21 @@ describe('ImageCanvasInteractionModel', () => { expect(zoomedViewport.x).toBeCloseTo(-10); expect(zoomedViewport.y).toBeCloseTo(10); expect(zoomedViewport.scale).toBe(1.1); + + expect( + zoomViewportFromWheel({ + viewport: { x: 10, y: 20, scale: 0.03 }, + deltaY: 120, + screenPoint: { x: 210, y: 120 }, + }).scale, + ).toBe(0.027); + expect( + zoomViewportFromWheel({ + viewport: { x: 10, y: 20, scale: 0.027 }, + deltaY: 120, + screenPoint: { x: 210, y: 120 }, + }).scale, + ).toBe(0.025); }); it('selects layers intersecting the marquee in screen space', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index c119945c1..854831a3c 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -660,6 +660,28 @@ describe('ImageCanvasWorldView', () => { ).toBe('4'); }); + it('does not inverse-scale the icon generator decorative tile when zoomed out', () => { + const dialog = createGenerationDialog({ + id: 'dialog-icon-decorative-scale', + mode: 'icon', + }); + + renderWorldView({ + viewport: { x: 0, y: 0, scale: 0.25 }, + canvasGenerationDialogs: [dialog], + generateDialog: null, + }); + + const frame = screen.getByRole('button', { name: '图标素材生成占位图' }); + const iconTile = frame.querySelector( + '.image-canvas-editor__generation-frame-icon', + ) as HTMLElement; + + expect( + iconTile.style.getPropertyValue('--image-canvas-editor-inverse-scale'), + ).toBe(''); + }); + it('uses a plain information icon for metadata corner actions', () => { const layer = createLayer(); const { props } = renderWorldView({ layers: [layer] }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 9b9626ebf..88d4b6d4f 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -1128,7 +1128,7 @@ export function ImageCanvasWorldView({ ) : null} {placeholderMeta.icon} From 7d3e652b53dce89c637462d7e887be5fe55c3af7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 23 Jun 2026 01:01:46 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=8A=A0=E8=BD=BD=E4=B8=8E=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=90=B8=E9=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目页封面和画布图层增加轻量加载态,图片解码不阻塞外框文字。 缩小等距吸附候选范围,降低多素材拖拽热路径开销。 补充项目封面、画布加载、吸附性能测试和文档说明。 --- docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- .../ImageCanvasEditorModel.test.ts | 55 +++++++++++ .../image-editor/ImageCanvasEditorModel.ts | 37 +++---- .../ImageCanvasWorldView.test.tsx | 17 ++++ .../image-editor/ImageCanvasWorldView.tsx | 82 +++++++++++++--- src/components/project/ProjectCanvasCover.tsx | 97 ++++++++++++++++--- .../project/ProjectGalleryView.test.tsx | 3 + src/index.css | 76 +++++++++++++++ 9 files changed, 330 insertions(+), 51 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c8fbefec2..cf7d200cd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -87,6 +87,14 @@ - 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 +## 图片画布素材多时拖拽卡顿先查等距吸附候选规模 + +- 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。 +- 原因:边缘 / 中心线吸附是线性扫描,但等距吸附如果对所有可吸附素材做两两配对,会在素材数量上来后进入 O(n²) 热路径。 +- 处理:保留边缘 / 中心线全量线性扫描;等距吸附先过滤跨轴相交素材,再只检查轴向邻近候选,不要为远处或不相交素材生成配对候选。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。 +- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasInteractionModel.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片编辑器宣发素材生成器刷新后不要丢快照 - 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 03a941f2a..01020cf68 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -33,6 +33,8 @@ - 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销重做、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 +- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 +- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 - 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -43,11 +45,11 @@ - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 -- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框),BFF 在生成成功后必须直接读取当前项目布局,只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 +- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 -- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。带 `canvasCompletion` 的图片生成以 BFF 返回的 `project` 快照刷新画布;无项目上下文或没有返回快照时,前端才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 +- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 0aff85bc9..5f3a56dc6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -707,4 +707,59 @@ describe('ImageCanvasEditorModel', () => { horizontal: 250, }); }); + + it('keeps equal-spacing snap focused on nearby overlapping layers', () => { + const movingLayer: CanvasLayer = { + id: 'moving', + resourceId: 'resource-moving', + title: '移动图', + src: 'data:image/png;base64,moving', + x: 0, + y: 0, + width: 80, + height: 80, + originalWidth: 80, + originalHeight: 80, + zIndex: 1, + sourceType: 'uploaded', + }; + const firstLayer: CanvasLayer = { + ...movingLayer, + id: 'first', + resourceId: 'resource-first', + x: 100, + y: 160, + zIndex: 2, + }; + const secondLayer: CanvasLayer = { + ...movingLayer, + id: 'second', + resourceId: 'resource-second', + x: 260, + y: 160, + zIndex: 3, + }; + const offscreenLayers = Array.from({ length: 80 }, (_, index) => ({ + ...movingLayer, + id: `offscreen-${index}`, + resourceId: `resource-offscreen-${index}`, + x: 2400 + index * 96, + y: 1600, + zIndex: 10 + index, + })); + + const snapped = resolveSnappedLayerPosition( + movingLayer, + 417, + 160, + [movingLayer, ...offscreenLayers, firstLayer, secondLayer], + 1, + ); + + expect(snapped.x).toBe(420); + expect(snapped.guide).toEqual({ + vertical: 460, + horizontal: 160, + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 75a033d9c..33dd17702 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -37,6 +37,7 @@ export const TOOLBAR_HALF_WIDTH = 132; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const SNAP_THRESHOLD_SCREEN_PX = 18; export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1; +const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3; export const FIT_VIEW_PADDING = 10; export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; @@ -1291,10 +1292,19 @@ function findNearestEqualSpacingSnap({ items: CanvasSnapItem[]; threshold: number; }): SnapCandidate | null { - const orderedItems = [...items].sort( - (firstItem, secondItem) => - getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis), - ); + const orderedItems = items + .filter((item) => + snapItemsOverlapOnCrossAxis( + proposedCrossStart, + movingCrossSize, + item, + axis, + ), + ) + .sort( + (firstItem, secondItem) => + getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis), + ); let nearest: SnapCandidate | null = null; for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) { @@ -1304,29 +1314,14 @@ function findNearestEqualSpacingSnap({ } for ( let secondIndex = firstIndex + 1; - secondIndex < orderedItems.length; + secondIndex < orderedItems.length && + secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD; secondIndex += 1 ) { const secondItem = orderedItems[secondIndex]; if (!secondItem) { continue; } - if ( - !snapItemsOverlapOnCrossAxis( - proposedCrossStart, - movingCrossSize, - firstItem, - axis, - ) || - !snapItemsOverlapOnCrossAxis( - proposedCrossStart, - movingCrossSize, - secondItem, - axis, - ) - ) { - continue; - } const firstStart = getSnapItemStart(firstItem, axis); const firstEnd = getSnapItemEnd(firstItem, axis); diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 854831a3c..7f7c2b6da 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -130,6 +130,23 @@ describe('ImageCanvasWorldView', () => { expect(screen.queryByRole('status', { name: '' })).toBeNull(); }); + it('keeps the image layer frame visible while the image is loading', () => { + useResolvedAssetReadUrlMock.mockImplementation(() => ({ + resolvedUrl: '', + isResolving: true, + shouldResolve: true, + })); + + renderWorldView(); + + const layerButton = screen.getByRole('button', { name: '选择角色主图' }); + + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeTruthy(); + expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull(); + }); + it('resolves image layers from objectKey before falling back to src', () => { useResolvedAssetReadUrlMock.mockImplementation( (_source: string, options?: { objectKey?: string | null }) => ({ diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 88d4b6d4f..473a82eea 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -629,17 +629,48 @@ function ImageCanvasImageLayer({ layer: CanvasLayer; mediaTransform?: string; }) { - const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, { - objectKey: layer.objectKey, - refreshKey: layer.taskId ?? layer.resourceId, - }); + const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl( + layer.src, + { + objectKey: layer.objectKey, + refreshKey: layer.taskId ?? layer.resourceId, + }, + ); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + setIsLoaded(false); + }, [resolvedUrl]); + + const isLoading = + isResolving || + (shouldResolve && !resolvedUrl) || + Boolean(resolvedUrl && !isLoaded); return ( - {`画布图片:${layer.title}`} + <> + {isLoading ? ( +