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, - ]} /> ) : (