diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 1f9036be..a550df1e 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,53 @@ 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', - ) - expect(screen.getByText('低饱和像素绘本')).toBeTruthy() - expect(container.querySelectorAll('[data-project-card]')).toHaveLength(2) + const previewProject = screen.getByRole('link', { name: '打开项目 点灯人 · MVP' }) + expect(previewProject.getAttribute('href')).toBe('/projects/42/assets') + expect(screen.getByRole('heading', { name: '最近项目 · 02' })).toBeTruthy() + const emptyProject = screen.getByRole('link', { name: '打开项目 空白海岸' }) + await waitFor(() => { + expect(previewProject.querySelector('img')?.getAttribute('src')).toBe( + 'https://cdn.windup.test/messenger-outfit.png', + ) + 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') === '6' + }), + ).toBe(true) }) it('sends creation to the project create page and deletes through the Project API', async () => { @@ -70,6 +112,149 @@ 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') + const emptyCharacter = { + ...character, + referenceImageUrl: null, + outfits: character.outfits.map((outfit) => ({ + ...outfit, + previewUrl: null, + actions: outfit.actions.map((action) => ({ ...action, frames: [] })), + })), + } + return { + items: [ + emptyCharacter, + { + ...character, + referenceImageUrl: Number(projectId) === 42 ? character.referenceImageUrl : null, + outfits: character.outfits.map((outfit) => ({ ...outfit, previewUrl: null })), + }, + ], + total: 1, + page: 1, + pageSize: 6, + } + }) + 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('limits preview request concurrency', async () => { + const backend = createProjectAssetsBackend({ projectCount: 5 }) + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + let activeRequests = 0 + let maxActiveRequests = 0 + let releaseRequests: (() => void) | undefined + const requestGate = new Promise((resolve) => { + releaseRequests = resolve + }) + const listSpy = vi.spyOn(characterApis, 'listByProject').mockImplementation(async () => { + activeRequests += 1 + maxActiveRequests = Math.max(maxActiveRequests, activeRequests) + await requestGate + activeRequests -= 1 + return { items: [], total: 0, page: 1, pageSize: 6 } + }) + render( + + + + + , + ) + + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(2)) + expect(maxActiveRequests).toBe(2) + releaseRequests?.() + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(5)) + expect(maxActiveRequests).toBe(2) + expect(listSpy.mock.calls.every(([, query]) => query?.page === 1 && query.pageSize === 6)).toBe( + true, + ) + }) + + it('shares concurrency across pages, deduplicates active requests, and reuses cached previews', async () => { + const backend = createProjectAssetsBackend({ projectCount: 13 }) + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + let activeRequests = 0 + let maxActiveRequests = 0 + let releaseRequests: (() => void) | undefined + const requestGate = new Promise((resolve) => { + releaseRequests = resolve + }) + const listSpy = vi.spyOn(characterApis, 'listByProject').mockImplementation(async () => { + activeRequests += 1 + maxActiveRequests = Math.max(maxActiveRequests, activeRequests) + await requestGate + activeRequests -= 1 + return { items: [], total: 0, page: 1, pageSize: 6 } + }) + render( + + + + + , + ) + + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('button', { name: '下一页' })) + await waitFor(() => { + expect(screen.getAllByRole('link', { name: /打开项目/ })).toHaveLength(1) + }) + expect(listSpy).toHaveBeenCalledTimes(2) + fireEvent.click(screen.getByRole('button', { name: '上一页' })) + await waitFor(() => { + expect(screen.getAllByRole('link', { name: /打开项目/ })).toHaveLength(12) + }) + expect(listSpy).toHaveBeenCalledTimes(2) + expect(new Set(listSpy.mock.calls.map(([projectId]) => projectId)).size).toBe(2) + + releaseRequests?.() + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(12)) + expect(maxActiveRequests).toBe(2) + fireEvent.click(screen.getByRole('button', { name: '下一页' })) + await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(13)) + fireEvent.click(screen.getByRole('button', { name: '上一页' })) + await waitFor(() => { + expect(screen.getAllByRole('link', { name: /打开项目/ })).toHaveLength(12) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(listSpy).toHaveBeenCalledTimes(13) + expect(maxActiveRequests).toBe(2) + }) + 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..804d5a9a 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,16 +1,32 @@ -import { useEffect, useState, type CSSProperties } from 'react' +import { useEffect, useRef, 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 +const PROJECT_PREVIEW_CHARACTER_LIMIT = 6 +const PROJECT_PREVIEW_REQUEST_CONCURRENCY = 2 + +interface ProjectPreviewRequest { + projectId: string + state: 'queued' | 'active' + cancelled: boolean + promise: Promise + resolve: (preview: string | null) => void +} /** 项目中心;项目是角色资产与生成规格的隔离边界。 */ export function ProjectsPage() { const [pageNumber, setPageNumber] = useState(1) const [projectsPage, setProjectsPage] = useState | null>(null) + const [projectPreviews, setProjectPreviews] = useState>({}) + const projectPreviewCache = useRef(new Map()) + const projectPreviewRequests = useRef(new Map()) + const projectPreviewQueue = useRef([]) + const activeProjectPreviewRequests = useRef(0) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) @@ -32,11 +48,115 @@ export function ProjectsPage() { } }, [pageNumber]) + useEffect(() => { + let active = true + if (!projectsPage) + return () => { + active = false + } + + const previews = Object.fromEntries( + projectsPage.items.map((project) => [ + project.id, + project.sampleImageUrl ?? projectPreviewCache.current.get(project.id) ?? null, + ]), + ) + setProjectPreviews(previews) + const projectsWithoutPreview = projectsPage.items.filter( + (project) => !project.sampleImageUrl && !projectPreviewCache.current.has(project.id), + ) + const requestedProjectIds = new Set(projectsWithoutPreview.map((project) => project.id)) + void Promise.all( + projectsWithoutPreview.map(async (project) => { + const preview = await loadProjectPreview(project.id) + return [project.id, preview] as const + }), + ).then((entries) => { + if (active) setProjectPreviews((current) => ({ ...current, ...Object.fromEntries(entries) })) + }) + + return () => { + active = false + cancelQueuedProjectPreviews(requestedProjectIds) + } + }, [projectsPage]) + + function loadProjectPreview(projectId: string): Promise { + if (projectPreviewCache.current.has(projectId)) { + return Promise.resolve(projectPreviewCache.current.get(projectId) ?? null) + } + const currentRequest = projectPreviewRequests.current.get(projectId) + if (currentRequest) return currentRequest.promise + + let resolvePreview: (preview: string | null) => void = () => undefined + const promise = new Promise((resolve) => { + resolvePreview = resolve + }) + const request: ProjectPreviewRequest = { + projectId, + state: 'queued', + cancelled: false, + promise, + resolve: resolvePreview, + } + projectPreviewRequests.current.set(projectId, request) + projectPreviewQueue.current.push(request) + processProjectPreviewQueue() + return promise + } + + function processProjectPreviewQueue() { + while ( + activeProjectPreviewRequests.current < PROJECT_PREVIEW_REQUEST_CONCURRENCY && + projectPreviewQueue.current.length > 0 + ) { + const request = projectPreviewQueue.current.shift() + if (!request || request.cancelled) continue + request.state = 'active' + activeProjectPreviewRequests.current += 1 + void (async () => { + let preview: string | null = null + let succeeded = false + try { + const page = await characterApis.listByProject(request.projectId, { + page: 1, + pageSize: PROJECT_PREVIEW_CHARACTER_LIMIT, + }) + preview = previewFromCharacters(page.items) + succeeded = true + } catch { + preview = null + } finally { + if (succeeded) projectPreviewCache.current.set(request.projectId, preview) + request.resolve(preview) + if (projectPreviewRequests.current.get(request.projectId) === request) { + projectPreviewRequests.current.delete(request.projectId) + } + activeProjectPreviewRequests.current -= 1 + processProjectPreviewQueue() + } + })() + } + } + + function cancelQueuedProjectPreviews(projectIds: Set) { + projectPreviewQueue.current = projectPreviewQueue.current.filter((request) => { + if (!projectIds.has(request.projectId) || request.state !== 'queued') return true + request.cancelled = true + request.resolve(null) + if (projectPreviewRequests.current.get(request.projectId) === request) { + projectPreviewRequests.current.delete(request.projectId) + } + return false + }) + } + async function deleteProject(project: Project) { setDeleting(true) setError(null) try { await projectApis.remove(project.id) + projectPreviewCache.current.delete(project.id) if (projectsPage?.items.length === 1 && projectsPage.page > 1) { setPageNumber(projectsPage.page - 1) } else { @@ -59,30 +179,18 @@ export function ProjectsPage() { } return ( - +
-
-
-

- 项目中心 -

-

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

-
- +

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

+

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

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

) : projectsPage === null ? (

正在读取项目…

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

还没有项目

-

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

-
) : ( -
- {projectsPage.items.map((project, index) => ( - setDeleteTarget(project)} +
+ + {projectsPage.items.length > 0 ? ( + - ))} + ) : null}
)} {projectsPage ? ( @@ -131,16 +233,114 @@ 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 previewFromCharacters(characters: Character[]): string | null { + for (const character of characters) { + const preview = previewFromCharacter(character) + if (preview) return preview + } + 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 +351,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() {

工作台

+

从这里开始,去任何地方