Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions frontend/src/entities/character/index.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<CharacterApis['listByProject']>()
.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) => {
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/entities/character/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -67,6 +77,29 @@ export interface CharacterApis {
remove(id: Character['id']): Promise<void>
}

/**
* 读取项目下的完整 Character 列表。需要先完整读取再筛选发布状态,避免草稿占用
* 服务端分页位置,导致后续已经发布的资产在前端永远不可见。
*/
export async function loadAllCharactersByProject(
apis: Pick<CharacterApis, 'listByProject'>,
projectId: string,
pageSize = 100,
): Promise<Character[]> {
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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
23 changes: 12 additions & 11 deletions frontend/src/pages/asset-library/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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)
})
})
52 changes: 34 additions & 18 deletions frontend/src/pages/asset-library/index.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,7 +19,7 @@ function characterName(character: Character) {
export function AssetLibraryPage() {
const { projectId } = useParams()
const [pageNumber, setPageNumber] = useState(1)
const [charactersPage, setCharactersPage] = useState<Paged<Character> | null>(null)
const [characters, setCharacters] = useState<Character[] | null>(null)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
Expand All @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Because pageNumber remains in this effect's dependency list, every next/previous-page click calls loadAllCharactersByProject again and redownloads the entire Character catalog before slicing 24 items. Pagination therefore performs O(all characters) network work and shows the loading state on every click. Fetch/cache the published list when projectId changes and paginate that state locally, or keep filtering/pagination on the server.

(items) => {
if (!active) return
setCharacters(items.filter(isPublishedCharacter))
},
() => {
if (active) setError('资产库暂时无法读取')
},
)
return () => {
active = false
}
}, [pageNumber, projectId])
}, [projectId])

const charactersPage = useMemo<Paged<Character> | 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 (
<section aria-labelledby="asset-library-title" className="min-h-full min-w-0">
Expand Down
27 changes: 26 additions & 1 deletion frontend/src/pages/project-detail/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<Response>(() => undefined)
}
return backend.fetch(input, init)
})

render(
<AuthenticatedAuthSession>
<MemoryRouter initialEntries={['/projects/42/assets']}>
<AppRoutes />
</MemoryRouter>
</AuthenticatedAuthSession>,
)

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()
Expand Down
32 changes: 17 additions & 15 deletions frontend/src/pages/project-detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
CHARACTER_PERSPECTIVE,
DIRECTIONAL_MOVEMENT,
characterApis,
isPublishedCharacter,
loadAllCharactersByProject,
projectApis,
type Project,
} from '@/entities'
Expand All @@ -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
Expand Down