From 15170636bf9682b82862ca47853c0b9fcf1ff68e Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 00:06:40 +0800 Subject: [PATCH 1/5] feat(projects): add visual project gallery The project center needs a calmer asset-first browsing surface. Render the create entrance, project previews, and empty pixel canvases while resolving real character preview images. Existing project creation, deletion, pagination, and navigation behavior stays intact. --- frontend/src/pages/projects/index.tsx | 244 ++++++++++++++++++-------- 1 file changed, 174 insertions(+), 70 deletions(-) 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}
From 2e2af1c998f7b96259235ddda8b0c2e88f06d106 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 00:06:51 +0800 Subject: [PATCH 2/5] test(projects): cover gallery preview behavior The redesigned gallery must keep its real API and navigation boundaries explicit. Cover character preview requests, empty canvases, creation links, deletion, and pagination. The tests now assert user-visible behavior instead of styling internals. --- frontend/src/pages/projects/index.test.tsx | 50 +++++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 1f9036be..2acfa810 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -22,8 +22,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 +32,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 () => { From a9203a567b82243a687d9d81067396b3794ffc21 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 00:07:02 +0800 Subject: [PATCH 3/5] feat(workspace): add opening tagline The workspace heading needs the same concise context used across the product surfaces. Add the approved tagline directly beneath the existing page title. No workspace navigation or data-loading behavior changes. --- frontend/src/pages/workspace/index.tsx | 1 + 1 file changed, 1 insertion(+) 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() {

工作台

+

从这里开始,去任何地方

From 1c28b5d9bd2f868a1ccc071ff6d0ff5f86acd13a Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 00:07:12 +0800 Subject: [PATCH 4/5] test(workspace): cover opening tagline The approved workspace context line should remain part of the rendered heading area. Assert the tagline alongside the existing navigation behavior. The focused coverage protects the copy without expanding workspace scope. --- frontend/src/pages/workspace/index.test.tsx | 1 + 1 file changed, 1 insertion(+) 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', ) From 7f3119c8676426ba48b978ff2cc40eb3f98f62f7 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 17:26:49 +0800 Subject: [PATCH 5/5] test(projects): cover preview fallbacks Codecov exposed untested project preview fallback branches. Exercise reference images, first frames, and isolated request failures. Keep gallery previews resilient without changing production behavior. --- frontend/src/pages/projects/index.test.tsx | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 2acfa810..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() }) @@ -108,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')