diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 1f9036be..d4462259 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,51 @@ describe('ProjectsPage', () => { ) expect(await screen.findByRole('heading', { name: '项目中心' })).toBeTruthy() + const createLink = await screen.findByRole('link', { name: '新建项目' }) + 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 +110,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..84660947 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,9 +1,10 @@ import { useEffect, useState, type CSSProperties } from 'react' import { Link } from 'react-router' -import { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis, type Project } from '@/entities' +import assetLibraryArtwork from '@/assets/workspace/asset-library.png' +import { characterApis, projectApis, type Character, type Project } from '@/entities' import type { Paged } from '@/shared/pagination' -import { PageContainer, Pagination } from '@/shared/ui' +import { Pagination } from '@/shared/ui' const PROJECT_PAGE_SIZE = 12 @@ -11,6 +12,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 +34,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 +90,18 @@ export function ProjectsPage() { } return ( - +
-
-
-

- 项目中心 -

-

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

-
- +

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

+

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

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

) : projectsPage === null ? (

正在读取项目…

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

还没有项目

-

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

-
) : ( -
- {projectsPage.items.map((project, index) => ( - setDeleteTarget(project)} +
+ + {projectsPage.items.length > 0 ? ( + - ))} + ) : null}
)} {projectsPage ? ( @@ -131,16 +144,106 @@ export function ProjectsPage() { onConfirm={() => deleteProject(deleteTarget)} /> ) : null} - +
+ ) +} + +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 ProjectCard({ +function ProjectGalleryTile({ project, + previewUrl, motionOrder, onDelete, }: { project: Project + previewUrl: string | null motionOrder: number onDelete: () => void }) { @@ -151,48 +254,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..607b389c 100644 --- a/frontend/src/pages/workspace/index.tsx +++ b/frontend/src/pages/workspace/index.tsx @@ -352,6 +352,7 @@ export function WorkspacePage() {

工作台

+

从这里开始,去任何地方