diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 857fb3680..547bd3249 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -83,7 +83,14 @@ Tab: - UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。 - 音乐:展示背景音乐和音效。 - 美宣:展示美宣类图片和视频素材。 -- 特效、场景、游戏:v1 只在有真实数据时展示,无数据时不造假卡片。 +- 特效、场景、游戏:v1 暂不实装生成链路,只在有真实数据时展示,无数据时不造假卡片,也不在 UI 中写“暂不实装”说明。 + +交互口径: + +- 点击任一精选列表项后打开接近全屏的素材预览弹窗。 +- 弹窗中部展示当前素材,图片使用 contain 完整展示,视频和音频展示可播放控件。 +- 弹窗底部固定显示当前组合的缩略图导航,点击缩略图切换当前素材。 +- 弹窗底部同时展示该列表项的提示词、作者名和生成总成本。 每个列表项需要标注: diff --git a/src/components/ResolvedAssetImage.tsx b/src/components/ResolvedAssetImage.tsx index 5f662f7b9..13060b386 100644 --- a/src/components/ResolvedAssetImage.tsx +++ b/src/components/ResolvedAssetImage.tsx @@ -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() ?? ''; diff --git a/src/components/ResolvedAssetVideo.tsx b/src/components/ResolvedAssetVideo.tsx index 4f0a8b020..97658d03d 100644 --- a/src/components/ResolvedAssetVideo.tsx +++ b/src/components/ResolvedAssetVideo.tsx @@ -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() || ''; diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 357decff8..ce026690d 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -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({ diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 6641d01f9..23c79b9f0 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -1,3 +1,4 @@ +import { Film, Image as ImageIcon, Music2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { @@ -5,23 +6,28 @@ import { listEditorProjects, loadEditorAssetLibrary, type EditorAssetLibrarySnapshot, - type EditorAssetSnapshot, type EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { ApiClientError } from '../../services/apiClient'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; +import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +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 { - resolvePlatformWorkAuthorDisplayName, - resolvePlatformWorldCoverImage, - resolvePlatformWorldCoverSlides, - type PlatformPublicGalleryCard, -} from '../rpg-entry/rpgEntryWorldPresentation'; + buildCreationShowcaseItems, + SHOWCASE_TABS, + type ShowcaseAssetItem, + type ShowcaseAssetPreview, + type ShowcaseTabId, +} from './creationShowcaseModel'; type CreationLandingViewProps = { onOpenProject: (projectId: string) => void; @@ -30,33 +36,12 @@ type CreationLandingViewProps = { publicGalleryEntries?: PlatformPublicGalleryCard[]; }; -type ShowcaseTabId = - | 'packs' - | 'characters' - | 'ui' - | 'music' - | 'effects' - | 'scenes' - | 'marketing' - | 'games'; - type CreationFeatureItem = { title: string; description: string; iconSrc: string; }; -type ShowcaseAssetItem = { - id: string; - label: string; - imageSrc: string; - width?: number; - height?: number; - prompt: string; - author: string; - cost: string; -}; - const CREATION_HOME_ASSETS = { heroCreate: '/creation-home/hero-create.png', heroCommunity: '/creation-home/hero-community.png', @@ -111,17 +96,6 @@ const CREATION_FEATURES: CreationFeatureItem[] = [ }, ]; -const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [ - { id: 'packs', label: '素材包' }, - { id: 'characters', label: '角色' }, - { id: 'ui', label: 'UI' }, - { id: 'music', label: '音乐' }, - { id: 'effects', label: '特效' }, - { id: 'scenes', label: '场景' }, - { id: 'marketing', label: '美宣' }, - { id: 'games', label: '游戏' }, -]; - function isUnauthorizedError(error: unknown) { return error instanceof ApiClientError && error.status === 401; } @@ -139,148 +113,247 @@ function formatProjectUpdatedAt(value: string) { }).format(date); } -function formatAssetCost(asset: EditorAssetSnapshot) { - const value = (asset as Record)['priceMudPoints']; - return typeof value === 'number' && Number.isFinite(value) - ? `${value}泥点` - : '-'; +function getPreviewAspectRatio(preview: ShowcaseAssetPreview) { + return preview.width && preview.height + ? `${Math.max(1, preview.width)} / ${Math.max(1, preview.height)}` + : undefined; } -function resolveAssetAuthor(asset: EditorAssetSnapshot) { - const value = (asset as Record)['authorName']; - return typeof value === 'string' && value.trim() ? value.trim() : '-'; +function getPreviewIcon(preview: ShowcaseAssetPreview) { + if (preview.mediaType === 'audio') { + return