完善创作主页精选素材预览

新增精选素材分组模型,按素材包、角色、UI、音乐、美宣等分类组合展示素材

补齐素材卡片和预览弹窗中的提示词、作者与生成总成本展示

新增接近全屏的素材预览弹窗,支持底部外置缩略图导航切换

让私有图片、视频和音频预览携带 objectKey 换签读取

补充创作主页素材展示文档和聚焦测试
This commit is contained in:
2026-06-22 11:00:05 +08:00
parent fc49ba2a61
commit f67d228c02
8 changed files with 1785 additions and 194 deletions
@@ -83,7 +83,14 @@ Tab
- UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。
- 音乐:展示背景音乐和音效。
- 美宣:展示美宣类图片和视频素材。
- 特效、场景、游戏:v1 只在有真实数据时展示,无数据时不造假卡片。
- 特效、场景、游戏:v1 暂不实装生成链路,只在有真实数据时展示,无数据时不造假卡片,也不在 UI 中写“暂不实装”说明
交互口径:
- 点击任一精选列表项后打开接近全屏的素材预览弹窗。
- 弹窗中部展示当前素材,图片使用 contain 完整展示,视频和音频展示可播放控件。
- 弹窗底部固定显示当前组合的缩略图导航,点击缩略图切换当前素材。
- 弹窗底部同时展示该列表项的提示词、作者名和生成总成本。
每个列表项需要标注:
+3
View File
@@ -8,12 +8,14 @@ type ResolvedAssetImageProps = Omit<
'src'
> & {
src?: string | null;
objectKey?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
export function ResolvedAssetImage({
src,
objectKey,
fallbackSrc,
alt,
refreshKey,
@@ -21,6 +23,7 @@ export function ResolvedAssetImage({
...rest
}: ResolvedAssetImageProps) {
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
objectKey,
refreshKey,
});
const normalizedSource = src?.trim() ?? '';
+3
View File
@@ -7,17 +7,20 @@ type ResolvedAssetVideoProps = Omit<
'src'
> & {
src?: string | null;
objectKey?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
export function ResolvedAssetVideo({
src,
objectKey,
fallbackSrc,
refreshKey,
...rest
}: ResolvedAssetVideoProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
objectKey,
refreshKey,
});
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
@@ -258,6 +258,80 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
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',
},
],
},
},
],
});
renderCreationLanding();
await user.click(screen.getByRole('tab', { name: '角色' }));
await user.click(await screen.findByText('角色英雄'));
const dialog = await screen.findByRole('dialog', { name: '角色英雄' });
expect(within(dialog).getByText('生成总成本')).toBeTruthy();
expect(within(dialog).getByText('60泥点')).toBeTruthy();
expect(within(dialog).getByText('创作者A')).toBeTruthy();
expect(
within(dialog).getByLabelText('素材缩略图导航'),
).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '查看待机动画' }));
expect(
within(dialog).getByRole('button', { name: '查看待机动画' }).getAttribute(
'aria-current',
),
).toBe('true');
});
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
listEditorProjectsMock.mockResolvedValueOnce([]);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,358 @@
import { describe, expect, it } from 'vitest';
import type {
EditorAssetLibrarySnapshot,
EditorAssetSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { buildCreationShowcaseItems } from './creationShowcaseModel';
function createAsset(
overrides: Partial<EditorAssetSnapshot> & Record<string, unknown>,
): EditorAssetSnapshot {
return {
assetId: 'asset-1',
folderId: 'folder-1',
label: '素材',
imageSrc: 'data:image/png;base64,asset',
width: 512,
height: 512,
sourceType: 'generated',
prompt: null,
actualPrompt: null,
model: null,
provider: null,
taskId: null,
...overrides,
};
}
function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot {
return {
folders: [],
assets,
};
}
describe('creationShowcaseModel', () => {
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',
label: '角色规范图',
assetKind: 'spec',
prompt: '规范提示词',
priceMudPoints: 5,
authorName: '创作者A',
}),
createAsset({
assetId: 'character-1',
label: '角色主图',
assetKind: 'character',
priceMudPoints: 12,
generationInputs: {
fields: [{ title: '角色设定', value: '角色提示词' }],
references: [
{
title: '角色规范',
label: '角色规范图',
refType: 'asset',
refId: 'spec-1',
},
],
},
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.label).toBe('角色规范图');
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
'角色规范图',
'角色主图',
]);
expect(items[0]?.prompt).toBe('规范提示词');
expect(items[0]?.author).toBe('创作者A');
expect(items[0]?.cost).toBe('17泥点');
});
it('groups assets when references point to the source project resource', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
assetLibrary: createLibrary([
createAsset({
assetId: 'spec-1',
label: '项目规范图',
assetKind: 'spec',
sourceResourceId: 'resource-spec-1',
}),
createAsset({
assetId: 'character-1',
label: '项目角色图',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '项目角色提示词' }],
references: [
{
title: '角色规范',
label: '项目规范图',
refType: 'project-resource',
refId: 'resource-spec-1',
},
],
},
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
'项目规范图',
'项目角色图',
]);
});
it('groups character animations under the referenced character image', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
assetLibrary: createLibrary([
createAsset({
assetId: 'character-1',
label: '游戏角色图',
assetKind: 'character',
prompt: '角色提示词',
}),
createAsset({
assetId: 'animation-1',
label: '待机动画',
assetKind: 'character-animation',
imageSrc: 'data:image/png;base64,animation',
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'asset',
refId: 'character-1',
},
],
},
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.label).toBe('游戏角色图');
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
'游戏角色图',
'待机动画',
]);
});
it('groups UI design extraction assets under the referenced UI image', () => {
const items = buildCreationShowcaseItems({
activeTab: 'ui',
assetLibrary: createLibrary([
createAsset({
assetId: 'ui-design-1',
label: '游戏UI图',
assetKind: 'ui-design',
prompt: 'UI提示词',
}),
createAsset({
assetId: 'icon-sheet-1',
label: 'UI子素材图集',
assetKind: 'icon-spritesheet',
generationInputs: {
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
references: [
{
title: 'UI设计图',
label: '游戏UI图',
refType: 'asset',
refId: 'ui-design-1',
},
],
},
}),
createAsset({
assetId: 'icon-1',
label: '开始按钮',
assetKind: 'icon',
generationInputs: {
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
references: [
{
title: 'UI设计图',
label: '游戏UI图',
refType: 'asset',
refId: 'ui-design-1',
},
],
},
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.label).toBe('游戏UI图');
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
'游戏UI图',
'UI子素材图集',
'开始按钮',
]);
});
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',
label: '游戏UI图',
assetKind: 'ui-design',
model: 'gemini-3.1-flash-image-preview',
taskId: 'task-ui-design',
}),
createAsset({
assetId: 'icon-sheet-1',
label: 'UI子素材图集',
assetKind: 'icon-spritesheet',
model: 'gpt-image-2',
taskId: 'task-ui-extract',
generationInputs: {
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
references: [
{
title: 'UI设计图',
label: '游戏UI图',
refType: 'asset',
refId: 'ui-design-1',
},
],
},
}),
createAsset({
assetId: 'icon-1',
label: '开始按钮',
assetKind: 'icon',
model: 'gpt-image-2',
taskId: 'task-ui-extract',
generationInputs: {
fields: [{ title: '提取提示词', value: '提取UI子素材' }],
references: [
{
title: 'UI设计图',
label: '游戏UI图',
refType: 'asset',
refId: 'ui-design-1',
},
],
},
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe('32泥点');
});
it('keeps music assets as audio preview items', () => {
const items = buildCreationShowcaseItems({
activeTab: 'music',
assetLibrary: createLibrary([
createAsset({
assetId: 'music-1',
label: '背景音乐',
imageSrc: '/generated-editor-audios/music.mp3',
assetKind: 'background-music',
prompt: '舒缓背景音乐',
priceMudPoints: '5',
}),
]),
publicGalleryEntries: [],
fallbackAuthorName: '当前用户',
});
expect(items).toHaveLength(1);
expect(items[0]?.previews[0]?.mediaType).toBe('audio');
expect(items[0]?.author).toBe('当前用户');
expect(items[0]?.cost).toBe('5泥点');
});
it('keeps character animation videos out of the marketing tab', () => {
const items = buildCreationShowcaseItems({
activeTab: 'marketing',
assetLibrary: createLibrary([
createAsset({
assetId: 'animation-1',
label: '角色动画',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
}),
createAsset({
assetId: 'poster-1',
label: '上线美宣视频',
assetKind: 'publication-material',
imageSrc: '/generated-editor-videos/poster.mp4',
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.label).toBe('上线美宣视频');
expect(items[0]?.previews[0]?.mediaType).toBe('video');
});
it('infers costs from generated asset kinds when stored price is absent', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
assetLibrary: createLibrary([
createAsset({
assetId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createAsset({
assetId: 'animation-1',
label: '待机动画',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
durationSeconds: 6,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'asset',
refId: 'character-1',
},
],
},
}),
]),
publicGalleryEntries: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe('140泥点');
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
});
});
File diff suppressed because it is too large Load Diff
+315
View File
@@ -3617,10 +3617,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
border: 1px solid rgba(220, 189, 163, 0.78);
border-radius: 1rem;
background: rgba(255, 254, 251, 0.88);
padding: 0;
color: inherit;
text-align: left;
box-shadow: 0 16px 32px rgba(124, 72, 35, 0.1);
cursor: zoom-in;
}
.creation-landing__asset-card:hover {
border-color: rgba(195, 104, 48, 0.46);
box-shadow: 0 20px 40px rgba(124, 72, 35, 0.14);
}
.creation-landing__asset-preview {
position: relative;
display: grid;
min-height: 9.5rem;
max-height: 23rem;
@@ -3629,6 +3639,64 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: linear-gradient(180deg, #fffdfa, #fff4ea);
}
.creation-landing__asset-preview--bundle {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 0.18rem;
padding: 0.18rem;
}
.creation-landing__asset-preview-tile {
position: relative;
display: grid;
width: 100%;
height: 100%;
min-height: 5rem;
overflow: hidden;
place-items: center;
border-radius: 0.72rem;
background: rgba(255, 255, 255, 0.66);
}
.creation-landing__asset-preview-more {
position: absolute;
right: 0.65rem;
bottom: 0.65rem;
display: inline-flex;
min-width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(44, 31, 22, 0.72);
color: #fffdf9;
font-size: 0.78rem;
font-weight: 850;
}
.creation-landing__asset-audio-preview {
display: grid;
width: 100%;
height: 100%;
min-height: 8rem;
place-items: center;
gap: 1rem;
padding: 1rem;
background:
linear-gradient(135deg, rgba(244, 187, 129, 0.4), rgba(255, 247, 236, 0.92)),
radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.9), transparent 42%);
color: #9a5428;
}
.creation-landing__asset-audio-preview svg {
width: 2.6rem;
height: 2.6rem;
}
.creation-landing__asset-audio-preview audio {
width: min(100%, 28rem);
}
.creation-landing__asset-meta {
display: grid;
gap: 0.55rem;
@@ -3675,6 +3743,215 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: nowrap;
}
.creation-landing__showcase-modal-overlay {
padding: 0.5rem;
background: rgba(11, 8, 6, 0.82);
}
.creation-landing__showcase-modal-panel {
width: min(100vw - 1rem, 88rem);
height: min(96vh, 64rem);
max-height: calc(100vh - 1rem);
overflow: hidden;
border-radius: 1.2rem;
background: #100d0b;
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5);
}
.creation-landing__showcase-modal-body {
display: flex;
min-height: 0;
flex: 1;
padding: 0;
overflow: hidden;
}
.creation-landing__showcase-modal {
position: relative;
display: grid;
width: 100%;
height: 100%;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
background:
radial-gradient(circle at 18% 12%, rgba(186, 117, 65, 0.18), transparent 32%),
#100d0b;
}
.creation-landing__showcase-modal-close {
z-index: 4;
}
.creation-landing__showcase-modal-stage {
position: relative;
z-index: 1;
display: grid;
min-height: 0;
overflow: hidden;
place-items: center;
padding: clamp(1rem, 3vw, 2.5rem);
}
.creation-landing__showcase-current {
position: relative;
display: grid;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
place-items: center;
}
.creation-landing__showcase-current > img,
.creation-landing__showcase-current > video,
.creation-landing__showcase-current .creation-landing__asset-audio-preview {
max-width: 100%;
max-height: 100%;
border-radius: 0.9rem;
}
.creation-landing__showcase-current > img,
.creation-landing__showcase-current > video {
width: auto;
height: auto;
object-fit: contain;
object-position: center;
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32);
}
.creation-landing__showcase-current > .creation-landing__showcase-media {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center;
}
.creation-landing__showcase-dock {
position: relative;
z-index: 3;
display: grid;
gap: 0.85rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(24, 18, 14, 0.94);
backdrop-filter: none;
padding: 0.85rem;
}
.creation-landing__showcase-dock-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(13rem, 0.44fr);
gap: 0.75rem 1rem;
align-items: start;
color: #fff8ef;
}
.creation-landing__showcase-dock-meta h3 {
margin: 0;
overflow: hidden;
color: #fff8ef;
font-size: 1rem;
font-weight: 860;
text-overflow: ellipsis;
white-space: nowrap;
}
.creation-landing__showcase-dock-meta p {
grid-column: 1 / 2;
display: -webkit-box;
min-height: 2.6em;
overflow: hidden;
margin: 0;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: rgba(255, 248, 239, 0.72);
font-size: 0.82rem;
font-weight: 620;
line-height: 1.32;
}
.creation-landing__showcase-dock-meta dl {
display: grid;
grid-column: 2 / 3;
grid-row: 1 / span 2;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
margin: 0;
}
.creation-landing__showcase-dock-meta div {
min-width: 0;
}
.creation-landing__showcase-dock-meta dt {
color: rgba(255, 248, 239, 0.5);
font-size: 0.68rem;
font-weight: 780;
}
.creation-landing__showcase-dock-meta dd {
overflow: hidden;
margin: 0;
color: #fff8ef;
font-size: 0.82rem;
font-weight: 820;
text-overflow: ellipsis;
white-space: nowrap;
}
.creation-landing__showcase-thumbs {
display: flex;
gap: 0.55rem;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.creation-landing__showcase-thumb {
display: grid;
flex: 0 0 6.5rem;
min-width: 0;
gap: 0.38rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.72rem;
background: rgba(255, 255, 255, 0.06);
padding: 0.38rem;
color: rgba(255, 248, 239, 0.72);
font-size: 0.68rem;
font-weight: 760;
text-align: center;
}
.creation-landing__showcase-thumb--active {
border-color: rgba(242, 184, 124, 0.8);
background: rgba(242, 184, 124, 0.18);
color: #fff8ef;
}
.creation-landing__showcase-thumb-media {
display: grid;
height: 4.1rem;
overflow: hidden;
place-items: center;
border-radius: 0.52rem;
background: rgba(255, 255, 255, 0.08);
}
.creation-landing__showcase-thumb-media svg {
width: 1.45rem;
height: 1.45rem;
}
.creation-landing__showcase-thumb > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.creation-landing__hero,
.creation-landing__section {
@@ -3718,6 +3995,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.creation-landing__asset-waterfall {
column-count: 2;
}
.creation-landing__showcase-modal-panel {
width: calc(100vw - 0.7rem);
height: calc(100vh - 0.7rem);
}
.creation-landing__showcase-dock-meta {
grid-template-columns: 1fr;
}
.creation-landing__showcase-dock-meta p,
.creation-landing__showcase-dock-meta dl {
grid-column: auto;
grid-row: auto;
}
}
@media (max-width: 560px) {
@@ -3763,6 +4055,29 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.creation-landing__asset-waterfall {
column-count: 1;
}
.creation-landing__showcase-modal-overlay {
padding: 0;
}
.creation-landing__showcase-modal-panel {
width: 100vw;
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
.creation-landing__showcase-modal-stage {
padding: 3.2rem 0.75rem 0.75rem;
}
.creation-landing__showcase-dock {
padding: 0.72rem;
}
.creation-landing__showcase-thumb {
flex-basis: 5.7rem;
}
}
.image-canvas-editor {