From 9b5fa75c2b27f1db2973a4542c604bc5ac04232d Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:24:07 +0800 Subject: [PATCH 1/2] fix(assets): hide draft characters from catalog --- frontend/src/entities/character/index.test.ts | 33 ++++++++++++++++ frontend/src/entities/character/index.ts | 33 ++++++++++++++++ frontend/src/entities/index.ts | 2 +- .../src/pages/asset-library/index.test.tsx | 16 ++++---- frontend/src/pages/asset-library/index.tsx | 39 ++++++++++++------- .../src/pages/project-detail/index.test.tsx | 4 +- frontend/src/pages/project-detail/index.tsx | 6 ++- 7 files changed, 107 insertions(+), 26 deletions(-) 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 4125ea91..6cdaad62 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 182f1cc4..8465efdf 100644 --- a/frontend/src/pages/asset-library/index.test.tsx +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -24,15 +24,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() @@ -47,8 +47,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( @@ -65,7 +65,7 @@ describe('AssetLibraryPage', () => { }) expect( backend.requests.some((request) => - request.url.includes('/characters?project_id=42&page=2&page_size=24'), + request.url.includes('/characters?project_id=42&page=1&page_size=100'), ), ).toBe(true) }) diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index 97cf2ef8..aac1cc58 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 { 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' @@ -28,19 +33,25 @@ export function AssetLibraryPage() { setCharactersPage(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 + const published = items.filter(isPublishedCharacter) + const total = published.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 + setCharactersPage({ + items: published.slice(start, start + CHARACTER_PAGE_SIZE), + total, + page: currentPage, + pageSize: CHARACTER_PAGE_SIZE, + }) + }, + () => { + if (active) setError('资产库暂时无法读取') + }, + ) return () => { active = false } diff --git a/frontend/src/pages/project-detail/index.test.tsx b/frontend/src/pages/project-detail/index.test.tsx index 5928f7e6..f0494d64 100644 --- a/frontend/src/pages/project-detail/index.test.tsx +++ b/frontend/src/pages/project-detail/index.test.tsx @@ -32,7 +32,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() diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index b8a8d67a..96a20f19 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' @@ -30,7 +32,7 @@ export function ProjectDetailPage() { setError(null) void Promise.allSettled([ projectApis.get(projectId), - characterApis.listByProject(projectId, { page: 1, pageSize: 1 }), + loadAllCharactersByProject(characterApis, projectId), ]).then(([projectResult, characterResult]) => { if (!active) return if (projectResult.status === 'rejected') { @@ -40,7 +42,7 @@ export function ProjectDetailPage() { setProject(projectResult.value) if (characterResult.status === 'fulfilled') { - setCharacterCount(characterResult.value.total) + setCharacterCount(characterResult.value.filter(isPublishedCharacter).length) } }) From ac815585f2c48bbe850f6038a672cb6e8c2e38d5 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:31:02 +0800 Subject: [PATCH 2/2] fix(assets): decouple loading and local pagination --- .../src/pages/asset-library/index.test.tsx | 9 ++--- frontend/src/pages/asset-library/index.tsx | 35 +++++++++++-------- .../src/pages/project-detail/index.test.tsx | 21 +++++++++++ frontend/src/pages/project-detail/index.tsx | 30 ++++++++-------- 4 files changed, 61 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/asset-library/index.test.tsx b/frontend/src/pages/asset-library/index.test.tsx index 8465efdf..5102f480 100644 --- a/frontend/src/pages/asset-library/index.test.tsx +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -58,15 +58,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=1&page_size=100'), - ), - ).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 aac1cc58..1e048824 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router' import { @@ -19,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(() => { @@ -31,22 +31,13 @@ export function AssetLibraryPage() { } } - setCharactersPage(null) + setPageNumber(1) + setCharacters(null) setError(null) void loadAllCharactersByProject(characterApis, projectId).then( (items) => { if (!active) return - const published = items.filter(isPublishedCharacter) - const total = published.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 - setCharactersPage({ - items: published.slice(start, start + CHARACTER_PAGE_SIZE), - total, - page: currentPage, - pageSize: CHARACTER_PAGE_SIZE, - }) + setCharacters(items.filter(isPublishedCharacter)) }, () => { if (active) setError('资产库暂时无法读取') @@ -55,7 +46,21 @@ export function AssetLibraryPage() { 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 f0494d64..1a75ab32 100644 --- a/frontend/src/pages/project-detail/index.test.tsx +++ b/frontend/src/pages/project-detail/index.test.tsx @@ -62,4 +62,25 @@ describe('ProjectDetailPage', () => { expect(screen.queryByRole('alert')).toBeNull() 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() + }) }) diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index 96a20f19..a98cff91 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -29,22 +29,22 @@ export function ProjectDetailPage() { } setProject(null) + setCharacterCount(0) setError(null) - void Promise.allSettled([ - projectApis.get(projectId), - loadAllCharactersByProject(characterApis, projectId), - ]).then(([projectResult, characterResult]) => { - if (!active) return - if (projectResult.status === 'rejected') { - setError('这个项目不存在或暂时无法读取') - return - } - - setProject(projectResult.value) - if (characterResult.status === 'fulfilled') { - setCharacterCount(characterResult.value.filter(isPublishedCharacter).length) - } - }) + 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