diff --git a/.github/assets/playtest/playtest-entry-desktop.png b/.github/assets/playtest/playtest-entry-desktop.png new file mode 100644 index 00000000..766a2e62 Binary files /dev/null and b/.github/assets/playtest/playtest-entry-desktop.png differ diff --git a/.github/assets/playtest/playtest-entry-mobile.png b/.github/assets/playtest/playtest-entry-mobile.png new file mode 100644 index 00000000..be0f7c97 Binary files /dev/null and b/.github/assets/playtest/playtest-entry-mobile.png differ diff --git a/frontend/src/pages/asset-library/index.test.tsx b/frontend/src/pages/asset-library/index.test.tsx index 58cb1bde..489a8140 100644 --- a/frontend/src/pages/asset-library/index.test.tsx +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -40,7 +40,7 @@ describe('AssetLibraryPage', () => { expect(preview.getAttribute('decoding')).toBe('async') expect(preview.getAttribute('fetchpriority')).toBe('high') expect(screen.getAllByText('1 套造型')).toHaveLength(1) - expect(screen.getByText('2 个动作')).toBeTruthy() + expect(screen.getByText('3 个动作')).toBeTruthy() expect(screen.queryByRole('searchbox')).toBeNull() expect(screen.queryByRole('button', { name: '导出全部角色资产' })).toBeNull() }) diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 63e0b3c5..c114c12f 100644 --- a/frontend/src/pages/character-detail/index.test.tsx +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -32,7 +32,7 @@ describe('CharacterDetailPage', () => { expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() expect(screen.getByRole('combobox', { name: '选择造型' })).toBeTruthy() - expect(screen.getAllByRole('article', { name: /动作/ })).toHaveLength(2) + expect(screen.getAllByRole('article', { name: /动作/ })).toHaveLength(3) expect(screen.getByRole('img', { name: '呼吸待机帧预览' }).getAttribute('src')).toBe( 'https://cdn.windup.test/idle-01.png', ) diff --git a/frontend/src/pages/playtest/entry.test.tsx b/frontend/src/pages/playtest/entry.test.tsx index 07e99c78..424f8381 100644 --- a/frontend/src/pages/playtest/entry.test.tsx +++ b/frontend/src/pages/playtest/entry.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -39,20 +39,38 @@ describe('PlaytestEntryPage', () => { it('links playable outfits to their concrete Playtest route', async () => { renderEntry() - expect(await screen.findByRole('heading', { name: '选择可预览资产' })).toBeTruthy() - expect(screen.getByTestId('playtest-pixel-stage').getAttribute('aria-hidden')).toBe('true') + expect(await screen.findByRole('heading', { name: '预览台' })).toBeTruthy() + expect(screen.queryByTestId('playtest-pixel-stage')).toBeNull() expect( (await screen.findByRole('link', { name: '预览 轻装信使 · 常态造型' })).getAttribute('href'), ).toBe('/playtest/51/outfit-default') + expect(screen.getAllByText('点灯人 · MVP').length).toBeGreaterThan(0) + expect(screen.getByText('呼吸待机 · 行走')).toBeTruthy() expect(screen.getByText('2 个动作 · 5 帧')).toBeTruthy() expect(screen.getByText('尚无可播放帧')).toBeTruthy() }) + it('filters the global outfit gallery by project without starting a second picker flow', async () => { + renderEntry() + + expect(await screen.findByRole('button', { name: '筛选项目 空白海岸' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '筛选项目 空白海岸' })) + + expect(screen.queryByRole('link', { name: '预览 轻装信使 · 常态造型' })).toBeNull() + expect(screen.getByText('这个项目还没有可预览造型')).toBeTruthy() + expect(screen.getByRole('button', { name: '筛选全部项目' }).getAttribute('aria-pressed')).toBe( + 'false', + ) + }) + it('directs an empty account back to character creation', async () => { renderEntry(0) expect(await screen.findByText('还没有可预览的角色')).toBeTruthy() - expect(screen.getByRole('link', { name: '开始创作' }).getAttribute('href')).toBe('/quick-start') + const createLink = screen.getByRole('link', { name: '开始创作' }) + expect(createLink.getAttribute('href')).toBe('/quick-start') + expect(createLink.getAttribute('data-ui')).toBe('editorial-entry-card') + expect(createLink.querySelector('img')?.getAttribute('src')).toContain('playtest.png') expect(screen.getByRole('link', { name: '查看项目资产' }).getAttribute('href')).toBe( '/projects', ) diff --git a/frontend/src/pages/playtest/entry.tsx b/frontend/src/pages/playtest/entry.tsx index 0805acbd..386c5b8f 100644 --- a/frontend/src/pages/playtest/entry.tsx +++ b/frontend/src/pages/playtest/entry.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router' import { @@ -10,9 +10,7 @@ import { type Project, } from '@/entities' import type { Paged } from '@/shared/pagination' -import { PageContainer } from '@/shared/ui' - -import { PlaytestPixelStage } from './pixel-stage' +import { EditorialEntryCard, PageContainer } from '@/shared/ui' interface ProjectCharacters { project: Project @@ -48,6 +46,7 @@ function characterName(character: Character) { */ export function PlaytestEntryPage() { const [state, setState] = useState(initialState) + const [selectedProjectId, setSelectedProjectId] = useState(null) useEffect(() => { let active = true @@ -78,39 +77,38 @@ export function PlaytestEntryPage() { } }, []) - const outfitCount = - state.groups?.reduce( - (total, group) => - total + group.characters.reduce((sum, character) => sum + character.outfits.length, 0), - 0, - ) ?? 0 + const outfits = useMemo( + () => + state.groups?.flatMap(({ project, characters }) => + characters.flatMap((character) => + character.outfits.map((outfit) => ({ project, character, outfit })), + ), + ) ?? [], + [state.groups], + ) + const visibleOutfits = selectedProjectId + ? outfits.filter(({ project }) => project.id === selectedProjectId) + : outfits + const outfitCount = outfits.length return (
-
-
-

- Character field test +

+
+

+ 预览台 +

+

+ 从已有造型进入操控测试,检查动作衔接、移动反馈和实际播放效果。

-
-

- 选择可预览资产 -

-

- 选择一套已有造型,检查动作衔接、移动反馈和实际播放效果。 -

-
-
- - {state.groups !== null ? `${outfitCount} 套造型已接入` : '正在接入资产'} -
- - +

+ {state.groups !== null ? `${outfitCount} 套造型已接入` : '正在接入资产'} +

{state.error ? ( @@ -120,43 +118,59 @@ export function PlaytestEntryPage() { ) : outfitCount === 0 ? ( ) : ( -
- {state.groups.map((group) => { - const charactersWithOutfits = group.characters.filter( - (character) => character.outfits.length > 0, - ) - if (charactersWithOutfits.length === 0) return null +
+
+ + {state.groups.map(({ project }) => ( + + ))} +
- return ( -
-
-

- {group.project.name} -

- - 查看项目资产 - -
-
- {charactersWithOutfits.flatMap((character) => - character.outfits.map((outfit) => ( - - )), - )} -
-
- ) - })} + {visibleOutfits.length === 0 ? ( +
+

这个项目还没有可预览造型

+

可以先去项目资产确认角色和造型。

+
+ ) : ( +
+ {visibleOutfits.map(({ project, character, outfit }) => ( + + ))} +
+ )}
)}
@@ -164,12 +178,22 @@ export function PlaytestEntryPage() { ) } -function OutfitCard({ character, outfit }: { character: Character; outfit: Outfit }) { +function OutfitCard({ + project, + character, + outfit, +}: { + project: Project + character: Character + outfit: Outfit +}) { const { frameCount, playable } = getOutfitPlayback(outfit) const name = characterName(character) + const playableActions = outfit.actions.filter((action) => action.frames.length > 0) + const actionSummary = playableActions.map((action) => action.name).join(' · ') const content = (
-
-

{name}

+
+

{project.name}

-

- {outfit.name} -

+
+

+ {name} +

+

{outfit.name}

+
-

- {playable ? `${outfit.actions.length} 个动作 · ${frameCount} 帧` : '尚无可播放帧'} -

+
+ {playable ? ( + <> +

{actionSummary}

+

+ {playableActions.length} 个动作 · {frameCount} 帧 +

+ + ) : ( +

尚无可播放帧

+ )} +
) @@ -226,7 +262,7 @@ function OutfitCard({ character, outfit }: { character: Character; outfit: Outfi {content} @@ -235,23 +271,19 @@ function OutfitCard({ character, outfit }: { character: Character; outfit: Outfi function EmptyState() { return ( -
-

- 还没有可预览的角色 -

-

- 完成角色与动作制作后,可以在这里检查移动和动画效果。 -

-
- - 开始创作 - +
+ +
查看项目资产 diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 1f9036be..fab992cb 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -4,11 +4,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' import { AppRoutes } from '@/app' +import { characterApis } from '@/entities' import { AuthenticatedAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllEnvs() vi.unstubAllGlobals() }) @@ -22,8 +24,8 @@ function installBackend() { describe('ProjectsPage', () => { it('renders backend Projects as the first browsing level', async () => { - installBackend() - const { container } = render( + const backend = installBackend() + render( @@ -32,13 +34,52 @@ describe('ProjectsPage', () => { ) expect(await screen.findByRole('heading', { name: '项目中心' })).toBeTruthy() + const createLink = await screen.findByRole('link', { name: '新建项目' }) + expect(createLink.getAttribute('data-ui')).toBe('editorial-entry-card') + const artwork = createLink.querySelector('img') + expect(artwork).toBeTruthy() + if (!artwork) throw new Error('新建项目入口缺少资产装饰图') + expect(artwork.getAttribute('src')).toContain('asset-library.png') + expect(artwork.getAttribute('aria-hidden')).toBe('true') + expect(screen.queryByText('新的资产空间')).toBeNull() + expect(screen.queryByText('按最近更新排列')).toBeNull() + expect(screen.getAllByRole('link', { name: '新建项目' })).toHaveLength(1) + expect(createLink.getAttribute('href')).toBe('/projects/new') expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) - expect(screen.getByRole('link', { name: '打开项目 点灯人 · MVP' }).getAttribute('href')).toBe( - '/projects/42/assets', + const previewProject = screen.getByRole('link', { name: '打开项目 点灯人 · MVP' }) + expect(previewProject.getAttribute('href')).toBe('/projects/42/assets') + expect(screen.getByRole('heading', { name: '最近项目 · 02' })).toBeTruthy() + expect(previewProject.querySelector('img')?.getAttribute('src')).toBe( + 'https://cdn.windup.test/messenger-outfit.png', ) - expect(screen.getByText('低饱和像素绘本')).toBeTruthy() - expect(container.querySelectorAll('[data-project-card]')).toHaveLength(2) + const emptyProject = screen.getByRole('link', { name: '打开项目 空白海岸' }) + expect(emptyProject.querySelector('img')).toBeNull() + expect(emptyProject.textContent).toContain('等待第一份角色资产') + expect(previewProject.textContent).toContain('08/04') + expect(screen.queryByText('项目名称')).toBeNull() + expect(screen.queryByText('视角 / 朝向')).toBeNull() expect(screen.queryByRole('link', { name: /查看角色/ })).toBeNull() + expect( + backend.requests.every((request) => + ['/projects', '/characters'].includes(new URL(request.url).pathname), + ), + ).toBe(true) + expect( + backend.requests.filter((request) => new URL(request.url).pathname === '/projects'), + ).toHaveLength(1) + const previewRequests = backend.requests.filter( + (request) => new URL(request.url).pathname === '/characters', + ) + expect(previewRequests).toHaveLength(2) + expect( + previewRequests.map((request) => new URL(request.url).searchParams.get('project_id')), + ).toEqual(['42', '99']) + expect( + previewRequests.every((request) => { + const query = new URL(request.url).searchParams + return query.get('page') === '1' && query.get('page_size') === '1' + }), + ).toBe(true) }) it('sends creation to the project create page and deletes through the Project API', async () => { @@ -70,6 +111,52 @@ describe('ProjectsPage', () => { ).toBe(true) }) + it('falls back through character preview sources without blocking the gallery', async () => { + const backend = createProjectAssetsBackend({ projectCount: 3 }) + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + const character = await characterApis.get('51') + vi.spyOn(characterApis, 'listByProject').mockImplementation(async (projectId) => { + if (Number(projectId) === 1002) throw new Error('preview unavailable') + return { + items: [ + { + ...character, + referenceImageUrl: Number(projectId) === 42 ? character.referenceImageUrl : null, + outfits: character.outfits.map((outfit) => ({ ...outfit, previewUrl: null })), + }, + ], + total: 1, + page: 1, + pageSize: 1, + } + }) + render( + + + + + , + ) + + expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(3) + await waitFor(() => { + expect( + screen + .getByRole('link', { name: '打开项目 点灯人 · MVP' }) + .querySelector('img') + ?.getAttribute('src'), + ).toBe('https://cdn.windup.test/messenger-reference.png') + expect( + screen + .getByRole('link', { name: '打开项目 空白海岸' }) + .querySelector('img') + ?.getAttribute('src'), + ).toBe('https://cdn.windup.test/idle-01.png') + expect(screen.getByText('等待第一份角色资产')).toBeTruthy() + }) + }) + it('navigates every backend Project page instead of truncating after the first page', async () => { const backend = createProjectAssetsBackend({ projectCount: 13 }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index d549893a..d5e5caba 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useState, type CSSProperties } from 'react' import { Link } from 'react-router' -import { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis, type Project } from '@/entities' +import { characterApis, projectApis, type Character, type Project } from '@/entities' import type { Paged } from '@/shared/pagination' -import { PageContainer, Pagination } from '@/shared/ui' +import { EditorialEntryCard, Pagination } from '@/shared/ui' const PROJECT_PAGE_SIZE = 12 @@ -11,6 +11,7 @@ const PROJECT_PAGE_SIZE = 12 export function ProjectsPage() { const [pageNumber, setPageNumber] = useState(1) const [projectsPage, setProjectsPage] = useState | null>(null) + const [projectPreviews, setProjectPreviews] = useState>({}) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) @@ -32,6 +33,35 @@ export function ProjectsPage() { } }, [pageNumber]) + useEffect(() => { + let active = true + if (!projectsPage) + return () => { + active = false + } + + setProjectPreviews( + Object.fromEntries(projectsPage.items.map((project) => [project.id, project.sampleImageUrl])), + ) + const projectsWithoutPreview = projectsPage.items.filter((project) => !project.sampleImageUrl) + void Promise.all( + projectsWithoutPreview.map(async (project) => { + try { + const page = await characterApis.listByProject(project.id, { page: 1, pageSize: 1 }) + return [project.id, previewFromCharacter(page.items[0])] as const + } catch { + return [project.id, null] as const + } + }), + ).then((entries) => { + if (active) setProjectPreviews((current) => ({ ...current, ...Object.fromEntries(entries) })) + }) + + return () => { + active = false + } + }, [projectsPage]) + async function deleteProject(project: Project) { setDeleting(true) setError(null) @@ -59,30 +89,18 @@ export function ProjectsPage() { } return ( - +
-
-
-

- 项目中心 -

-

- 项目隔离角色资产与生成规格;先选项目,再管理其资产。 -

-
- +

- + 新建项目 - + 项目中心 +

+

+ 项目隔离角色资产与生成规格;先选项目,再管理其资产。 +

{error ? ( @@ -94,23 +112,17 @@ export function ProjectsPage() {

) : projectsPage === null ? (

正在读取项目…

- ) : projectsPage.total === 0 ? ( -
-

还没有项目

-

- 从右上角新建一个项目,之后它会显示在这里。 -

-
) : ( -
- {projectsPage.items.map((project, index) => ( - setDeleteTarget(project)} +
+ + {projectsPage.items.length > 0 ? ( + - ))} + ) : null}
)} {projectsPage ? ( @@ -131,16 +143,82 @@ export function ProjectsPage() { onConfirm={() => deleteProject(deleteTarget)} /> ) : null} - +
) } -function ProjectCard({ +function previewFromCharacter(character: Character | undefined): string | null { + if (!character) return null + for (const outfit of character.outfits) { + if (outfit.previewUrl) return outfit.previewUrl + } + if (character.referenceImageUrl) return character.referenceImageUrl + for (const outfit of character.outfits) { + for (const action of outfit.actions) { + const frame = action.frames.find((item) => item.imageUrl) + if (frame) return frame.imageUrl + } + } + return null +} + +function ProjectCreateCard() { + return ( + + ) +} + +function ProjectGallery({ + projects, + total, + previews, + onDelete, +}: { + projects: Project[] + total: number + previews: Record + onDelete: (project: Project) => void +}) { + return ( +
+
+ +
+
+ {projects.map((project, index) => ( + onDelete(project)} + /> + ))} +
+
+ ) +} + +function ProjectGalleryTile({ project, + previewUrl, motionOrder, onDelete, }: { project: Project + previewUrl: string | null motionOrder: number onDelete: () => void }) { @@ -151,48 +229,49 @@ function ProjectCard({ return (
-
-

- {project.name} -

-

更新于 {updatedAt}

-
-
-
视角 / 朝向
-
- {CHARACTER_PERSPECTIVE[project.perspective]} ·{' '} - {DIRECTIONAL_MOVEMENT[project.directionalMovement]} -
-
-
-
精灵尺寸
-
- {project.spriteSize.width} × {project.spriteSize.height} -
-
-
-
画风约束
-
- {project.gameStyle ?? '尚未设定'} -
+
+ {previewUrl ? ( + {`${project.name}的项目预览`} + ) : ( +
+ -
+ )} +
+
+

{project.name}

+ {updatedAt}
diff --git a/frontend/src/pages/workspace/index.test.tsx b/frontend/src/pages/workspace/index.test.tsx index b62cffef..d98541f6 100644 --- a/frontend/src/pages/workspace/index.test.tsx +++ b/frontend/src/pages/workspace/index.test.tsx @@ -233,6 +233,7 @@ describe('WorkspacePage', () => { renderWorkspace() expect(screen.getByRole('heading', { name: '工作台' })).toBeTruthy() + expect(screen.getByText('从这里开始,去任何地方')).toBeTruthy() expect(screen.getByRole('link', { name: '进入快速开始' }).getAttribute('href')).toBe( '/quick-start', ) diff --git a/frontend/src/pages/workspace/index.tsx b/frontend/src/pages/workspace/index.tsx index 659728d2..25781cc0 100644 --- a/frontend/src/pages/workspace/index.tsx +++ b/frontend/src/pages/workspace/index.tsx @@ -352,6 +352,7 @@ export function WorkspacePage() {

工作台

+

从这里开始,去任何地方

@@ -826,7 +827,7 @@ function OutfitSelection({ {playback.playable - ? `${outfit.actions.length} 个动作 · ${playback.frameCount} 帧` + ? `${outfit.actions.filter((action) => action.frames.length > 0).length} 个动作 · ${playback.frameCount} 帧` : '尚无可播放帧'} diff --git a/frontend/src/shared/ui/editorial-entry-card.tsx b/frontend/src/shared/ui/editorial-entry-card.tsx new file mode 100644 index 00000000..e9a93776 --- /dev/null +++ b/frontend/src/shared/ui/editorial-entry-card.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router' + +import assetLibraryArtwork from '@/assets/workspace/asset-library.png' +import playtestArtwork from '@/assets/workspace/playtest.png' + +export type EditorialEntryArtwork = 'asset-library' | 'playtest' + +export interface EditorialEntryCardProps { + action: ReactNode + ariaLabel: string + artwork: EditorialEntryArtwork + description: ReactNode + title: ReactNode + to: string +} + +/** 产品内容页共用的横向入口卡;右侧像素物件只负责提示入口语义。 */ +export function EditorialEntryCard({ + action, + ariaLabel, + artwork, + description, + title, + to, +}: EditorialEntryCardProps) { + const artworkUrl = artwork === 'asset-library' ? assetLibraryArtwork : playtestArtwork + + return ( + +
+

+ {title} +

+

{description}

+ + {action} + +
+
+ +
+ + ) +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 8aeaf703..b1b7dfbb 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,3 +1,5 @@ +export { EditorialEntryCard } from './editorial-entry-card' +export type { EditorialEntryArtwork, EditorialEntryCardProps } from './editorial-entry-card' export { PageContainer } from './page-container' export type { PageContainerProps } from './page-container' export { Pagination } from './pagination' diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts index b16ccc9e..fcb903bb 100644 --- a/frontend/src/test/project-assets-backend.ts +++ b/frontend/src/test/project-assets-backend.ts @@ -104,6 +104,15 @@ const characterDtos = [ }, ], }, + { + id: 'attack-draft', + type: 'attack', + name: '未完成攻击', + loop: false, + fps: 10, + frame_count: 0, + frames: [], + }, ], }, ],