diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index af0a21de..1e2434a1 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isPublishedCharacter, + loadAllCharactersByProject, + type Character, + type CharacterApis, +} from './index' + const characterDto = { id: 51, project_id: 42, @@ -61,6 +68,32 @@ function jsonResponse(data: unknown) { } describe('characterApis', () => { + it('treats a Character as published only after an action contains real frames', () => { + const draft = { outfits: [{ actions: [] }] } as unknown as Character + const published = { + outfits: [{ actions: [{ frames: [{ imageUrl: 'frame.png' }] }] }], + } as unknown as Character + + expect(isPublishedCharacter(draft)).toBe(false) + expect(isPublishedCharacter(published)).toBe(true) + }) + + it('loads every backend page before a caller filters published assets', async () => { + const first = { id: '1' } as Character + const second = { id: '2' } as Character + const listByProject = vi + .fn() + .mockResolvedValueOnce({ items: [first], total: 2, page: 1, pageSize: 1 }) + .mockResolvedValueOnce({ items: [second], total: 2, page: 2, pageSize: 1 }) + + await expect(loadAllCharactersByProject({ listByProject }, '42', 1)).resolves.toEqual([ + first, + second, + ]) + expect(listByProject).toHaveBeenNthCalledWith(1, '42', { page: 1, pageSize: 1 }) + expect(listByProject).toHaveBeenNthCalledWith(2, '42', { page: 2, pageSize: 1 }) + }) + it('maps the paged Character tree and its project query', async () => { let requestUrl = '' const characterApis = await loadCharacterApis(async (input) => { diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 43c2cf6c..f27b6cd7 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -47,6 +47,16 @@ export interface Character { outfits: Outfit[] } +/** + * 当前后端没有草稿/已发布字段,因此以至少一条包含真实帧的动作作为发布判定。 + * 后端补充显式发布状态后,只需要替换这一处规则。 + */ +export function isPublishedCharacter(character: Character): boolean { + return character.outfits.some((outfit) => + outfit.actions.some((action) => action.frames.length > 0), + ) +} + /** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */ export interface CreateCharacterInput { projectId: string @@ -67,6 +77,29 @@ export interface CharacterApis { remove(id: Character['id']): Promise } +/** + * 读取项目下的完整 Character 列表。需要先完整读取再筛选发布状态,避免草稿占用 + * 服务端分页位置,导致后续已经发布的资产在前端永远不可见。 + */ +export async function loadAllCharactersByProject( + apis: Pick, + projectId: string, + pageSize = 100, +): Promise { + const firstPage = await apis.listByProject(projectId, { page: 1, pageSize }) + const characters = [...firstPage.items] + if (firstPage.pageSize <= 0) return characters + + let page = firstPage.page + 1 + while (characters.length < firstPage.total) { + const nextPage = await apis.listByProject(projectId, { page, pageSize }) + if (nextPage.items.length === 0) break + characters.push(...nextPage.items) + page += 1 + } + return characters +} + interface CharacterFrameDto { index: number image_url: string diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 8e7c3c79..eabab482 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -26,7 +26,7 @@ export type { Frame, Outfit, } from './character' -export { characterApis } from './character' +export { characterApis, isPublishedCharacter, loadAllCharactersByProject } from './character' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' diff --git a/frontend/src/pages/asset-library/index.test.tsx b/frontend/src/pages/asset-library/index.test.tsx index 8989aeb0..35c98c06 100644 --- a/frontend/src/pages/asset-library/index.test.tsx +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -27,15 +27,15 @@ function renderRoute(route: string) { } describe('AssetLibraryPage', () => { - it('renders only backend Character assets and their nested counts', async () => { + it('hides draft characters until they contain a published action', async () => { renderRoute('/projects/42/assets') expect(await screen.findByRole('heading', { name: '角色' })).toBeTruthy() - expect(await screen.findAllByRole('link', { name: /查看角色/ })).toHaveLength(2) + expect(await screen.findAllByRole('link', { name: /查看角色/ })).toHaveLength(1) expect(screen.getByText('轻装信使')).toBeTruthy() - expect(screen.getByText('待定角色')).toBeTruthy() - expect(screen.getByText('暂无造型预览')).toBeTruthy() - expect(screen.getAllByText('1 套造型')).toHaveLength(2) + expect(screen.queryByText('待定角色')).toBeNull() + expect(screen.queryByText('暂无造型预览')).toBeNull() + expect(screen.getAllByText('1 套造型')).toHaveLength(1) expect(screen.getByText('2 个动作')).toBeTruthy() expect(screen.queryByRole('searchbox')).toBeNull() expect(screen.queryByRole('button', { name: '导出全部角色资产' })).toBeNull() @@ -50,8 +50,8 @@ describe('AssetLibraryPage', () => { expect(screen.queryByRole('link', { name: /查看角色/ })).toBeNull() }) - it('navigates every backend Character page instead of truncating after the first page', async () => { - const backend = createProjectAssetsBackend({ characterCount: 25 }) + it('paginates all published characters after removing drafts', async () => { + const backend = createProjectAssetsBackend({ characterCount: 26 }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', backend.fetch) render( @@ -63,15 +63,16 @@ describe('AssetLibraryPage', () => { ) expect(await screen.findAllByRole('link', { name: /查看角色/ })).toHaveLength(24) + const requestsBeforePaging = backend.requests.filter((request) => + request.url.includes('/characters?project_id=42'), + ).length fireEvent.click(screen.getByRole('button', { name: '下一页' })) await waitFor(() => { expect(screen.getAllByRole('link', { name: /查看角色/ })).toHaveLength(1) }) expect( - backend.requests.some((request) => - request.url.includes('/characters?project_id=42&page=2&page_size=24'), - ), - ).toBe(true) + backend.requests.filter((request) => request.url.includes('/characters?project_id=42')), + ).toHaveLength(requestsBeforePaging) }) }) diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index 97cf2ef8..1e048824 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -1,7 +1,12 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router' -import { characterApis, type Character } from '@/entities' +import { + characterApis, + isPublishedCharacter, + loadAllCharactersByProject, + type Character, +} from '@/entities' import type { Paged } from '@/shared/pagination' import { Pagination } from '@/shared/ui' @@ -14,7 +19,7 @@ function characterName(character: Character) { export function AssetLibraryPage() { const { projectId } = useParams() const [pageNumber, setPageNumber] = useState(1) - const [charactersPage, setCharactersPage] = useState | null>(null) + const [characters, setCharacters] = useState(null) const [error, setError] = useState(null) useEffect(() => { @@ -26,25 +31,36 @@ export function AssetLibraryPage() { } } - setCharactersPage(null) + setPageNumber(1) + setCharacters(null) setError(null) - void characterApis - .listByProject(projectId, { - page: pageNumber, - pageSize: CHARACTER_PAGE_SIZE, - }) - .then( - (page) => { - if (active) setCharactersPage(page) - }, - () => { - if (active) setError('资产库暂时无法读取') - }, - ) + void loadAllCharactersByProject(characterApis, projectId).then( + (items) => { + if (!active) return + setCharacters(items.filter(isPublishedCharacter)) + }, + () => { + if (active) setError('资产库暂时无法读取') + }, + ) return () => { active = false } - }, [pageNumber, projectId]) + }, [projectId]) + + const charactersPage = useMemo | null>(() => { + if (characters === null) return null + const total = characters.length + const lastPage = Math.max(1, Math.ceil(total / CHARACTER_PAGE_SIZE)) + const currentPage = Math.min(pageNumber, lastPage) + const start = (currentPage - 1) * CHARACTER_PAGE_SIZE + return { + items: characters.slice(start, start + CHARACTER_PAGE_SIZE), + total, + page: currentPage, + pageSize: CHARACTER_PAGE_SIZE, + } + }, [characters, pageNumber]) return (
diff --git a/frontend/src/pages/project-detail/index.test.tsx b/frontend/src/pages/project-detail/index.test.tsx index 1733c42a..691e947d 100644 --- a/frontend/src/pages/project-detail/index.test.tsx +++ b/frontend/src/pages/project-detail/index.test.tsx @@ -38,7 +38,9 @@ describe('ProjectDetailPage', () => { expect(screen.getByRole('link', { name: '返回项目中心' }).getAttribute('href')).toBe( '/projects', ) - expect(screen.getByRole('link', { name: /角色/ }).getAttribute('aria-current')).toBe('page') + expect((await screen.findByRole('link', { name: '角色1' })).getAttribute('aria-current')).toBe( + 'page', + ) expect(screen.getByRole('button', { name: '动作模板' }).hasAttribute('disabled')).toBe(true) expect(screen.queryByText('穿戴')).toBeNull() expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() @@ -69,6 +71,29 @@ describe('ProjectDetailPage', () => { expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() }) + it('renders the Project workspace without waiting for the character count request', async () => { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + if (request.url.includes('/characters?project_id=42')) { + return new Promise(() => undefined) + } + return backend.fetch(input, init) + }) + + render( + + + + + , + ) + + expect(await screen.findByRole('heading', { name: '点灯人 · MVP' })).toBeTruthy() + expect(screen.getByText('64 × 64')).toBeTruthy() + }) + it('shows the current account in the workspace and returns home after logout', async () => { const backend = createProjectAssetsBackend() const apis = createAuthenticatedTestApis() diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index b1944dff..9f0ad65a 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -5,6 +5,8 @@ import { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, characterApis, + isPublishedCharacter, + loadAllCharactersByProject, projectApis, type Project, } from '@/entities' @@ -29,22 +31,22 @@ export function ProjectDetailPage() { } setProject(null) + setCharacterCount(0) setError(null) - void Promise.allSettled([ - projectApis.get(projectId), - characterApis.listByProject(projectId, { page: 1, pageSize: 1 }), - ]).then(([projectResult, characterResult]) => { - if (!active) return - if (projectResult.status === 'rejected') { - setError('这个项目不存在或暂时无法读取') - return - } - - setProject(projectResult.value) - if (characterResult.status === 'fulfilled') { - setCharacterCount(characterResult.value.total) - } - }) + void projectApis.get(projectId).then( + (nextProject) => { + if (active) setProject(nextProject) + }, + () => { + if (active) setError('这个项目不存在或暂时无法读取') + }, + ) + void loadAllCharactersByProject(characterApis, projectId).then( + (characters) => { + if (active) setCharacterCount(characters.filter(isPublishedCharacter).length) + }, + () => undefined, + ) return () => { active = false