限制创作主页精选素材来源

只从项目内画布生成资源构建陶泥儿精选

移除公开作品和账号素材库精选回退

补充精选数据边界测试和项目记忆文档
This commit is contained in:
2026-06-22 23:19:02 +08:00
parent 7830d201c4
commit ac3befdadf
7 changed files with 341 additions and 410 deletions
@@ -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 编辑器生成模型默认定价调整
- 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。
@@ -15,7 +15,7 @@
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`
- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。
- `陶泥儿精选` 展示已经存在于用户图片画布项目中的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
## 页面结构
@@ -61,9 +61,9 @@
### 陶泥儿精选
`陶泥儿精选` 是页面底部的用户素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
`陶泥儿精选` 是页面底部的项目内画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容优先使用真实账号级素材和生成结果数据;未登录或账号素材为空时,可使用已公开作品中真实用户生成的图片作为瀑布流素材来源。两种来源都不使用 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/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
## 路由与导航
@@ -196,9 +197,9 @@ git diff --check
- 移动视口底部不显示“创作”和“项目”。
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`
- “我的”页没有项目快捷入口。
- 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。
- 陶泥儿精选素材瀑布流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。
- 陶泥儿精选不出现玩法入口卡;即使使用公开作品图片补充展示,也必须呈现为用户素材瀑布流
- 陶泥儿精选不出现玩法入口卡公开作品图片补充展示。
## 非目标
@@ -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<ContextType<typeof AuthUiContext>>;
@@ -48,7 +46,6 @@ function createAuthValue(overrides: Partial<AuthValue> = {}): 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(
<AuthUiContext.Provider value={authValue}>
@@ -85,7 +80,6 @@ function renderCreationLanding({
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
onOpenCommunity={vi.fn()}
publicGalleryEntries={publicGalleryEntries}
/>
</AuthUiContext.Provider>,
);
@@ -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(
<AuthUiContext.Provider value={createAuthValue()}>
@@ -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(
<AuthUiContext.Provider value={createAuthValue()}>
@@ -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();
});
});
@@ -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<string | null>(null);
const [assetLibrary, setAssetLibrary] =
useState<EditorAssetLibrarySnapshot | null>(null);
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
EditorProjectResourceSnapshot[]
>([]);
const [assetError, setAssetError] = useState<string | null>(null);
const [activeShowcaseTab, setActiveShowcaseTab] =
useState<ShowcaseTabId>('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) {
@@ -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<EditorAssetSnapshot> & Record<string, unknown>,
): EditorAssetSnapshot {
function createResource(
overrides: Partial<EditorProjectResourceSnapshot> & Record<string, unknown>,
): 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',
);
@@ -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<string, unknown>;
}
function projectResourceRecord(resource: EditorProjectResourceSnapshot) {
return resource as unknown as Record<string, unknown>;
}
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<string>();
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<string>();
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);
}
@@ -15283,10 +15283,6 @@ export function PlatformEntryFlowShellImpl({
onOpenProject={openEditorProject}
onOpenProjects={openProjectGallery}
onOpenCommunity={openCreationCommunity}
publicGalleryEntries={[
...featuredGalleryEntries,
...latestGalleryEntries,
]}
/>
</Suspense>
) : (