From f42c8f722ed2b7c97b0c9755f3bccc1c0fbd40a5 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 19 Aug 2026 14:50:15 +0800 Subject: [PATCH 1/7] perf(projects): aggregate project previews Project cards currently trigger one character request per project. Add a bounded window query and expose the resolved preview on project lists. Project listing now resolves all card previews with one character query. --- .../windup_app/server/project/interface.py | 10 ++- .../src/windup_app/server/project/service.py | 76 ++++++++++++++++++- .../app/src/windup_app/web/api/project.py | 27 ++++++- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py index 74618ece..ed82ca1c 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -27,7 +27,9 @@ def create_project(self, session: Session, **fields) -> Project: """ @abstractmethod - def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: """判断用户下的项目名称是否已存在。""" @abstractmethod @@ -46,6 +48,12 @@ def list_projects( ) -> tuple[list[Project], int]: """分页查询项目,返回 (当前页数据, 总数)。""" + @abstractmethod + def list_project_previews( + self, session: Session, project_ids: list[int], *, character_limit: int + ) -> dict[int, str | None]: + """一次查询当前页项目的资产预览,结果包含所有传入项目 ID。""" + @abstractmethod def delete_project(self, session: Session, project_id: int) -> bool: """删除项目并返回是否找到。""" diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py index 2845b348..3c039992 100644 --- a/backend/packages/app/src/windup_app/server/project/service.py +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -13,6 +13,27 @@ from windup_app.server.project.interface import ProjectService from windup_app.server.project.model import Project +from windup_app.server.character.model import Character + + +def _character_preview( + reference_image_url: str | None, character_data: dict +) -> str | None: + """沿用项目中心既有优先级:造型预览、角色参考图、第一张动作帧。""" + outfits = character_data.get("outfits", []) + for outfit in outfits: + preview = outfit.get("preview_url") + if preview: + return preview + if reference_image_url: + return reference_image_url + for outfit in outfits: + for action in outfit.get("actions", []): + for frame in action.get("frames", []): + image_url = frame.get("image_url") + if image_url: + return image_url + return None class SqlAlchemyProjectService(ProjectService): @@ -24,7 +45,9 @@ def create_project(self, session: Session, **fields) -> Project: session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at) return project - def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: stmt = ( select(Project.id) .where(Project.user_id == user_id, Project.project_name == project_name) @@ -50,10 +73,59 @@ def list_projects( count_stmt = count_stmt.where(Project.user_id == user_id) stmt = stmt.where(Project.user_id == user_id) total = session.scalar(count_stmt) or 0 - stmt = stmt.order_by(Project.id.desc()).offset((page - 1) * page_size).limit(page_size) + stmt = ( + stmt.order_by(Project.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) items = list(session.scalars(stmt)) return items, total + def list_project_previews( + self, session: Session, project_ids: list[int], *, character_limit: int + ) -> dict[int, str | None]: + previews = dict.fromkeys(project_ids) + if not project_ids: + return previews + + rank = ( + func.row_number() + .over( + partition_by=Character.project_id, + order_by=Character.id.desc(), + ) + .label("project_rank") + ) + ranked = ( + select( + Character.project_id.label("project_id"), + Character.reference_image_url.label("reference_image_url"), + Character.character_data.label("character_data"), + rank, + ) + .where(Character.project_id.in_(project_ids)) + .subquery() + ) + stmt = ( + select( + ranked.c.project_id, + ranked.c.reference_image_url, + ranked.c.character_data, + ranked.c.project_rank, + ) + .where(ranked.c.project_rank <= character_limit) + .order_by(ranked.c.project_id, ranked.c.project_rank) + ) + for project_id, reference_image_url, character_data, _rank in session.execute( + stmt + ): + if previews[project_id] is None: + previews[project_id] = _character_preview( + reference_image_url, + character_data or {}, + ) + return previews + def delete_project(self, session: Session, project_id: int) -> bool: project = session.get(Project, project_id) if project is None: diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py index a51af31d..5058821c 100644 --- a/backend/packages/app/src/windup_app/web/api/project.py +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -52,6 +52,12 @@ class ProjectOut(BaseModel): update_at: datetime +class ProjectListOut(ProjectOut): + """项目列表项;预览是读取投影,不写回 Project。""" + + preview_url: str | None + + @router.post("", response_model=Response[ProjectOut]) def create_project( body: ProjectCreate, @@ -81,19 +87,30 @@ def create_project( return Response.success(ProjectOut.model_validate(project), message="创建成功") -@router.get("", response_model=ListResponse[ProjectOut]) +@router.get("", response_model=ListResponse[ProjectListOut]) def list_projects( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), session: Session = Depends(get_session), -) -> ListResponse[ProjectOut]: +) -> ListResponse[ProjectListOut]: user_id = request.state.current_user.id projects, total = service.list_projects( session, page=page, page_size=page_size, user_id=user_id ) + previews = service.list_project_previews( + session, + [project.id for project in projects], + character_limit=6, + ) return ListResponse.success( - [ProjectOut.model_validate(item) for item in projects], + [ + ProjectListOut( + **ProjectOut.model_validate(item).model_dump(), + preview_url=item.sprite_sample_url or previews[item.id], + ) + for item in projects + ], total=total, page=page, page_size=page_size, @@ -127,5 +144,7 @@ def delete_project( service.delete_project(session, project_id) except IntegrityError: session.rollback() - raise BizException("项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST) from None + raise BizException( + "项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST + ) from None return Response.success(None, message="删除成功") From 35af5ca90b0c1c1c8e63a6039bfea967fc6f17bb Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 19 Aug 2026 14:50:15 +0800 Subject: [PATCH 2/7] perf(projects): consume list previews The project center maintains a client-side queue for missing card previews. Read the preview projection returned by the project list and remove the queue. Opening the project center no longer requests characters for each card. --- frontend/src/entities/project/index.ts | 4 + frontend/src/pages/projects/index.tsx | 157 +------------------------ 2 files changed, 7 insertions(+), 154 deletions(-) diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 90373565..6c015e58 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -11,6 +11,8 @@ export interface Project { spriteSize: { width: number; height: number } gameStyle: string | null sampleImageUrl: string | null + /** 项目列表已经解析好的卡片预览;详情响应不承诺提供。 */ + previewUrl?: string | null createdAt: string updatedAt: string } @@ -91,6 +93,7 @@ interface ProjectDto { sprite_height: number game_style: string | null sprite_sample_url: string | null + preview_url?: string | null create_at: string update_at: string } @@ -152,6 +155,7 @@ function mapProject(dto: ProjectDto): Project { spriteSize: { width: dto.sprite_width, height: dto.sprite_height }, gameStyle: dto.game_style, sampleImageUrl: dto.sprite_sample_url, + previewUrl: dto.preview_url ?? dto.sprite_sample_url, createdAt: dto.create_at, updatedAt: dto.update_at, } diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index b9366813..cd9ade7f 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,38 +1,17 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useEffect, useState, type CSSProperties } from 'react' import { Link } from 'react-router' import assetLibraryArtwork from '@/assets/workspace/asset-library.png' -import { - characterApis, - projectApis, - ProjectHasCharactersError, - type Character, - type Project, -} from '@/entities' +import { projectApis, ProjectHasCharactersError, type Project } from '@/entities' import type { Paged } from '@/shared/pagination' 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) @@ -54,115 +33,11 @@ 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 { @@ -218,7 +93,6 @@ export function ProjectsPage() { ) : null} @@ -246,29 +120,6 @@ export function ProjectsPage() { ) } -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 ( onDelete: (project: Project) => void }) { return ( @@ -332,7 +181,7 @@ function ProjectGallery({ onDelete(project)} /> From 19a99895a815ad496c43b487237d229a3c4ee9b5 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 19 Aug 2026 14:50:15 +0800 Subject: [PATCH 3/7] test(projects): cover preview aggregation Project preview priority and query count need an executable contract. Exercise every fallback and count real character SELECT statements. The tests prevent project count from reintroducing query fan-out. --- backend/tests/test_project_api.py | 142 ++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py index 8b797ba1..b69a77e2 100644 --- a/backend/tests/test_project_api.py +++ b/backend/tests/test_project_api.py @@ -5,6 +5,8 @@ ``timestamp`` 默认省略)与 400/404 业务码路径。 """ +from sqlalchemy import event + def _payload(**overrides): """构造合法的创建请求体(对齐 ``ProjectCreate``)。""" @@ -103,6 +105,128 @@ def test_list_paginates(auth_client): assert all("user_id" not in item for item in body["data"]) +def _create_character(auth_client, project_id, workflow_run_id, **overrides): + payload = { + "project_id": project_id, + "workflow_run_id": workflow_run_id, + "name": f"角色 {workflow_run_id}", + "character_data": {"version": 1, "outfits": []}, + } + payload.update(overrides) + body = auth_client.post("/characters", json=payload).json() + assert body["code"] == 200 + return body["data"] + + +def test_list_includes_project_preview_fallbacks(auth_client): + outfit_project = auth_client.post( + "/projects", json=_payload(project_name="造型预览") + ).json()["data"] + reference_project = auth_client.post( + "/projects", json=_payload(project_name="参考图预览") + ).json()["data"] + frame_project = auth_client.post( + "/projects", json=_payload(project_name="帧预览") + ).json()["data"] + auth_client.post("/projects", json=_payload(project_name="空项目")) + + _create_character( + auth_client, + outfit_project["id"], + 601, + reference_image_url="https://cdn.windup.test/reference-unused.png", + character_data={ + "version": 1, + "outfits": [ + { + "id": "outfit-1", + "name": "常态", + "preview_url": "https://cdn.windup.test/outfit.png", + "actions": [], + } + ], + }, + ) + _create_character( + auth_client, + reference_project["id"], + 602, + reference_image_url="https://cdn.windup.test/reference.png", + ) + _create_character( + auth_client, + frame_project["id"], + 603, + character_data={ + "version": 1, + "outfits": [ + { + "id": "outfit-3", + "name": "常态", + "preview_url": None, + "actions": [ + { + "id": "idle", + "type": "idle", + "name": "待机", + "loop": True, + "fps": 8, + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://cdn.windup.test/frame.png", + } + ], + } + ], + } + ], + }, + ) + + body = auth_client.get("/projects", params={"page_size": 10}).json() + previews = {item["project_name"]: item["preview_url"] for item in body["data"]} + + assert previews == { + "空项目": None, + "帧预览": "https://cdn.windup.test/frame.png", + "参考图预览": "https://cdn.windup.test/reference.png", + "造型预览": "https://cdn.windup.test/outfit.png", + } + + +def test_list_loads_all_project_previews_with_one_character_query(auth_client, engine): + first = auth_client.post( + "/projects", json=_payload(project_name="固定查询一") + ).json()["data"] + second = auth_client.post( + "/projects", json=_payload(project_name="固定查询二") + ).json()["data"] + _create_character(auth_client, first["id"], 611) + _create_character(auth_client, second["id"], 612) + statements = [] + + def record_character_select( + _conn, _cursor, statement, _parameters, _context, _many + ): + normalized = statement.lower() + if ( + normalized.lstrip().startswith("select") + and "windup_character" in normalized + ): + statements.append(statement) + + event.listen(engine, "before_cursor_execute", record_character_select) + try: + body = auth_client.get("/projects", params={"page_size": 10}).json() + finally: + event.remove(engine, "before_cursor_execute", record_character_select) + + assert body["code"] == 200 + assert len(statements) == 1 + + # -- DELETE /projects/{id} --------------------------------------------------- @@ -125,9 +249,9 @@ def test_delete_not_found_returns_404(auth_client): def test_delete_rejected_when_project_has_characters(auth_client): - created = auth_client.post("/projects", json=_payload(project_name="有角色")).json()[ - "data" - ] + created = auth_client.post( + "/projects", json=_payload(project_name="有角色") + ).json()["data"] character = auth_client.post( "/characters", json={ @@ -147,10 +271,14 @@ def test_delete_rejected_when_project_has_characters(auth_client): assert body["message"] == "项目下仍有角色,无法删除" assert body["data"] is None assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200 - assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + assert ( + auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + ) -def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, monkeypatch): +def test_delete_rejected_when_character_arrives_after_empty_check( + auth_client, monkeypatch +): """模拟检查与删除之间插入角色:应用层已看见空项目,数据库仍应拦住删除。""" created = auth_client.post("/projects", json=_payload(project_name="竞态")).json()[ "data" @@ -177,4 +305,6 @@ def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, m assert body["code"] == 400 assert body["message"] == "项目下仍有角色,无法删除" assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200 - assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + assert ( + auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + ) From 3d96a8ca81b1b7465fe09e96a1bcf85cd38a7700 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 19 Aug 2026 14:50:15 +0800 Subject: [PATCH 4/7] test(projects): verify single-request previews Project cards must render previews without the character list adapter. Model list previews in the HTTP fixture and assert zero character requests. The page tests preserve pagination, empty states, and card previews. --- frontend/src/entities/project/index.test.ts | 2 + frontend/src/pages/projects/index.test.tsx | 147 ++------------------ frontend/src/test/project-assets-backend.ts | 4 + 3 files changed, 18 insertions(+), 135 deletions(-) diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts index 9c19c0fc..f137bc6e 100644 --- a/frontend/src/entities/project/index.test.ts +++ b/frontend/src/entities/project/index.test.ts @@ -10,6 +10,7 @@ const projectDto = { sprite_height: 96, game_style: null, sprite_sample_url: 'https://cdn.windup.test/style.png', + preview_url: 'https://cdn.windup.test/project-preview.png', create_at: '2026-08-01T08:00:00Z', update_at: '2026-08-02T09:30:00Z', } @@ -61,6 +62,7 @@ describe('projectApis', () => { spriteSize: { width: 64, height: 96 }, gameStyle: null, sampleImageUrl: 'https://cdn.windup.test/style.png', + previewUrl: 'https://cdn.windup.test/project-preview.png', createdAt: '2026-08-01T08:00:00Z', updatedAt: '2026-08-02T09:30:00Z', }, diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index e405d67e..37cfe153 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -4,7 +4,6 @@ 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' @@ -60,27 +59,15 @@ describe('ProjectsPage', () => { 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.every((request) => new URL(request.url).pathname === '/projects')).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) + backend.requests.filter((request) => new URL(request.url).pathname === '/characters'), + ).toHaveLength(0) }) it('sends creation to the project create page and deletes through the Project API', async () => { @@ -133,36 +120,10 @@ describe('ProjectsPage', () => { expect(screen.getByRole('link', { name: '打开项目 点灯人 · MVP' })).toBeTruthy() }) - it('falls back through character preview sources without blocking the gallery', async () => { + it('uses project previews and keeps missing previews in the empty state', 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( @@ -178,102 +139,18 @@ describe('ProjectsPage', () => { .getByRole('link', { name: '打开项目 点灯人 · MVP' }) .querySelector('img') ?.getAttribute('src'), - ).toBe('https://cdn.windup.test/messenger-reference.png') + ).toBe('https://cdn.windup.test/messenger-outfit.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 } + ).toBeUndefined() + expect(screen.getAllByText('等待第一份角色资产')).toHaveLength(1) }) - 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) + expect( + backend.requests.filter((request) => new URL(request.url).pathname === '/characters'), + ).toHaveLength(0) }) it('navigates every backend Project page instead of truncating after the first page', async () => { diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts index b16ccc9e..7f1e1dea 100644 --- a/frontend/src/test/project-assets-backend.ts +++ b/frontend/src/test/project-assets-backend.ts @@ -9,6 +9,7 @@ interface ProjectDto { sprite_height: number game_style: string | null sprite_sample_url: string | null + preview_url: string | null create_at: string update_at: string } @@ -24,6 +25,7 @@ const projectDtos: ProjectDto[] = [ sprite_height: 64, game_style: '低饱和像素绘本', sprite_sample_url: null, + preview_url: 'https://cdn.windup.test/messenger-outfit.png', create_at: '2026-08-01T08:00:00Z', update_at: '2026-08-04T10:30:00Z', }, @@ -37,6 +39,7 @@ const projectDtos: ProjectDto[] = [ sprite_height: 128, game_style: null, sprite_sample_url: null, + preview_url: null, create_at: '2026-08-02T08:00:00Z', update_at: '2026-08-03T09:00:00Z', }, @@ -229,6 +232,7 @@ export function createProjectAssetsBackend({ sprite_height: body.sprite_height, game_style: body.game_style ?? null, sprite_sample_url: body.sprite_sample_url ?? null, + preview_url: body.sprite_sample_url ?? null, create_at: '2026-08-06T00:00:00Z', update_at: '2026-08-06T00:00:00Z', } From 1f837ccd797020f66d8f5362ff8ee38591281a90 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 19 Aug 2026 14:50:15 +0800 Subject: [PATCH 5/7] chore(openapi): publish project preview contract The project collection now exposes a resolved preview URL. Regenerate the checked-in OpenAPI schema from the updated route model. API consumers can distinguish project list and detail representations. --- openapi.json | 107 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/openapi.json b/openapi.json index 1cc8e732..7228ca1b 100644 --- a/openapi.json +++ b/openapi.json @@ -898,7 +898,7 @@ "title": "ListResponse[CreditTransactionOut]", "type": "object" }, - "ListResponse_ProjectOut_": { + "ListResponse_ProjectListOut_": { "properties": { "code": { "default": 200, @@ -909,7 +909,7 @@ "data": { "description": "业务数据列表", "items": { - "$ref": "#/components/schemas/ProjectOut" + "$ref": "#/components/schemas/ProjectListOut" }, "title": "Data", "type": "array" @@ -954,7 +954,7 @@ "type": "integer" } }, - "title": "ListResponse[ProjectOut]", + "title": "ListResponse[ProjectListOut]", "type": "object" }, "ListResponse_WorkflowRunOut_": { @@ -1225,6 +1225,105 @@ "title": "ProjectCreate", "type": "object" }, + "ProjectListOut": { + "description": "项目列表项;预览是读取投影,不写回 Project。", + "properties": { + "character_perspective": { + "title": "Character Perspective", + "type": "integer" + }, + "create_at": { + "format": "date-time", + "title": "Create At", + "type": "string" + }, + "directional_movement": { + "title": "Directional Movement", + "type": "integer" + }, + "game_style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Game Style" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "preview_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Preview Url" + }, + "project_name": { + "title": "Project Name", + "type": "string" + }, + "sprite_height": { + "title": "Sprite Height", + "type": "integer" + }, + "sprite_sample_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sprite Sample Url" + }, + "sprite_width": { + "title": "Sprite Width", + "type": "integer" + }, + "update_at": { + "format": "date-time", + "title": "Update At", + "type": "string" + }, + "workflow_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + "required": [ + "id", + "workflow_id", + "project_name", + "character_perspective", + "directional_movement", + "sprite_width", + "sprite_height", + "game_style", + "sprite_sample_url", + "create_at", + "update_at", + "preview_url" + ], + "title": "ProjectListOut", + "type": "object" + }, "ProjectOut": { "description": "项目响应。", "properties": { @@ -3138,7 +3237,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListResponse_ProjectOut_" + "$ref": "#/components/schemas/ListResponse_ProjectListOut_" } } }, From d7e3a5c59950287fa973b3f13112dd1933093230 Mon Sep 17 00:00:00 2001 From: huyanxius <178478063+huyanxius@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:04:32 +0000 Subject: [PATCH 6/7] chore: refresh PR mergeability From 81155975c9f385a0a8e016150ce36b4b2ec7549e Mon Sep 17 00:00:00 2001 From: huyanxius <178478063+huyanxius@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:11:07 +0000 Subject: [PATCH 7/7] perf(projects): eliminate preview request waterfall Replay the project preview aggregation changes on the latest main and retain the card-thumbnail fallback test. --- .../windup_app/server/project/interface.py | 10 +- .../src/windup_app/server/project/service.py | 76 +++- .../app/src/windup_app/web/api/project.py | 27 +- backend/tests/test_project_api.py | 142 +++++++- frontend/src/entities/project/index.test.ts | 2 + frontend/src/entities/project/index.ts | 4 + frontend/src/pages/projects/index.test.tsx | 332 +----------------- frontend/src/pages/projects/index.tsx | 219 +----------- frontend/src/test/project-assets-backend.ts | 4 + openapi.json | 107 +++++- 10 files changed, 388 insertions(+), 535 deletions(-) diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py index 74618ece..ed82ca1c 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -27,7 +27,9 @@ def create_project(self, session: Session, **fields) -> Project: """ @abstractmethod - def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: """判断用户下的项目名称是否已存在。""" @abstractmethod @@ -46,6 +48,12 @@ def list_projects( ) -> tuple[list[Project], int]: """分页查询项目,返回 (当前页数据, 总数)。""" + @abstractmethod + def list_project_previews( + self, session: Session, project_ids: list[int], *, character_limit: int + ) -> dict[int, str | None]: + """一次查询当前页项目的资产预览,结果包含所有传入项目 ID。""" + @abstractmethod def delete_project(self, session: Session, project_id: int) -> bool: """删除项目并返回是否找到。""" diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py index 2845b348..3c039992 100644 --- a/backend/packages/app/src/windup_app/server/project/service.py +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -13,6 +13,27 @@ from windup_app.server.project.interface import ProjectService from windup_app.server.project.model import Project +from windup_app.server.character.model import Character + + +def _character_preview( + reference_image_url: str | None, character_data: dict +) -> str | None: + """沿用项目中心既有优先级:造型预览、角色参考图、第一张动作帧。""" + outfits = character_data.get("outfits", []) + for outfit in outfits: + preview = outfit.get("preview_url") + if preview: + return preview + if reference_image_url: + return reference_image_url + for outfit in outfits: + for action in outfit.get("actions", []): + for frame in action.get("frames", []): + image_url = frame.get("image_url") + if image_url: + return image_url + return None class SqlAlchemyProjectService(ProjectService): @@ -24,7 +45,9 @@ def create_project(self, session: Session, **fields) -> Project: session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at) return project - def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: stmt = ( select(Project.id) .where(Project.user_id == user_id, Project.project_name == project_name) @@ -50,10 +73,59 @@ def list_projects( count_stmt = count_stmt.where(Project.user_id == user_id) stmt = stmt.where(Project.user_id == user_id) total = session.scalar(count_stmt) or 0 - stmt = stmt.order_by(Project.id.desc()).offset((page - 1) * page_size).limit(page_size) + stmt = ( + stmt.order_by(Project.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) items = list(session.scalars(stmt)) return items, total + def list_project_previews( + self, session: Session, project_ids: list[int], *, character_limit: int + ) -> dict[int, str | None]: + previews = dict.fromkeys(project_ids) + if not project_ids: + return previews + + rank = ( + func.row_number() + .over( + partition_by=Character.project_id, + order_by=Character.id.desc(), + ) + .label("project_rank") + ) + ranked = ( + select( + Character.project_id.label("project_id"), + Character.reference_image_url.label("reference_image_url"), + Character.character_data.label("character_data"), + rank, + ) + .where(Character.project_id.in_(project_ids)) + .subquery() + ) + stmt = ( + select( + ranked.c.project_id, + ranked.c.reference_image_url, + ranked.c.character_data, + ranked.c.project_rank, + ) + .where(ranked.c.project_rank <= character_limit) + .order_by(ranked.c.project_id, ranked.c.project_rank) + ) + for project_id, reference_image_url, character_data, _rank in session.execute( + stmt + ): + if previews[project_id] is None: + previews[project_id] = _character_preview( + reference_image_url, + character_data or {}, + ) + return previews + def delete_project(self, session: Session, project_id: int) -> bool: project = session.get(Project, project_id) if project is None: diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py index a51af31d..5058821c 100644 --- a/backend/packages/app/src/windup_app/web/api/project.py +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -52,6 +52,12 @@ class ProjectOut(BaseModel): update_at: datetime +class ProjectListOut(ProjectOut): + """项目列表项;预览是读取投影,不写回 Project。""" + + preview_url: str | None + + @router.post("", response_model=Response[ProjectOut]) def create_project( body: ProjectCreate, @@ -81,19 +87,30 @@ def create_project( return Response.success(ProjectOut.model_validate(project), message="创建成功") -@router.get("", response_model=ListResponse[ProjectOut]) +@router.get("", response_model=ListResponse[ProjectListOut]) def list_projects( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), session: Session = Depends(get_session), -) -> ListResponse[ProjectOut]: +) -> ListResponse[ProjectListOut]: user_id = request.state.current_user.id projects, total = service.list_projects( session, page=page, page_size=page_size, user_id=user_id ) + previews = service.list_project_previews( + session, + [project.id for project in projects], + character_limit=6, + ) return ListResponse.success( - [ProjectOut.model_validate(item) for item in projects], + [ + ProjectListOut( + **ProjectOut.model_validate(item).model_dump(), + preview_url=item.sprite_sample_url or previews[item.id], + ) + for item in projects + ], total=total, page=page, page_size=page_size, @@ -127,5 +144,7 @@ def delete_project( service.delete_project(session, project_id) except IntegrityError: session.rollback() - raise BizException("项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST) from None + raise BizException( + "项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST + ) from None return Response.success(None, message="删除成功") diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py index 8b797ba1..b69a77e2 100644 --- a/backend/tests/test_project_api.py +++ b/backend/tests/test_project_api.py @@ -5,6 +5,8 @@ ``timestamp`` 默认省略)与 400/404 业务码路径。 """ +from sqlalchemy import event + def _payload(**overrides): """构造合法的创建请求体(对齐 ``ProjectCreate``)。""" @@ -103,6 +105,128 @@ def test_list_paginates(auth_client): assert all("user_id" not in item for item in body["data"]) +def _create_character(auth_client, project_id, workflow_run_id, **overrides): + payload = { + "project_id": project_id, + "workflow_run_id": workflow_run_id, + "name": f"角色 {workflow_run_id}", + "character_data": {"version": 1, "outfits": []}, + } + payload.update(overrides) + body = auth_client.post("/characters", json=payload).json() + assert body["code"] == 200 + return body["data"] + + +def test_list_includes_project_preview_fallbacks(auth_client): + outfit_project = auth_client.post( + "/projects", json=_payload(project_name="造型预览") + ).json()["data"] + reference_project = auth_client.post( + "/projects", json=_payload(project_name="参考图预览") + ).json()["data"] + frame_project = auth_client.post( + "/projects", json=_payload(project_name="帧预览") + ).json()["data"] + auth_client.post("/projects", json=_payload(project_name="空项目")) + + _create_character( + auth_client, + outfit_project["id"], + 601, + reference_image_url="https://cdn.windup.test/reference-unused.png", + character_data={ + "version": 1, + "outfits": [ + { + "id": "outfit-1", + "name": "常态", + "preview_url": "https://cdn.windup.test/outfit.png", + "actions": [], + } + ], + }, + ) + _create_character( + auth_client, + reference_project["id"], + 602, + reference_image_url="https://cdn.windup.test/reference.png", + ) + _create_character( + auth_client, + frame_project["id"], + 603, + character_data={ + "version": 1, + "outfits": [ + { + "id": "outfit-3", + "name": "常态", + "preview_url": None, + "actions": [ + { + "id": "idle", + "type": "idle", + "name": "待机", + "loop": True, + "fps": 8, + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://cdn.windup.test/frame.png", + } + ], + } + ], + } + ], + }, + ) + + body = auth_client.get("/projects", params={"page_size": 10}).json() + previews = {item["project_name"]: item["preview_url"] for item in body["data"]} + + assert previews == { + "空项目": None, + "帧预览": "https://cdn.windup.test/frame.png", + "参考图预览": "https://cdn.windup.test/reference.png", + "造型预览": "https://cdn.windup.test/outfit.png", + } + + +def test_list_loads_all_project_previews_with_one_character_query(auth_client, engine): + first = auth_client.post( + "/projects", json=_payload(project_name="固定查询一") + ).json()["data"] + second = auth_client.post( + "/projects", json=_payload(project_name="固定查询二") + ).json()["data"] + _create_character(auth_client, first["id"], 611) + _create_character(auth_client, second["id"], 612) + statements = [] + + def record_character_select( + _conn, _cursor, statement, _parameters, _context, _many + ): + normalized = statement.lower() + if ( + normalized.lstrip().startswith("select") + and "windup_character" in normalized + ): + statements.append(statement) + + event.listen(engine, "before_cursor_execute", record_character_select) + try: + body = auth_client.get("/projects", params={"page_size": 10}).json() + finally: + event.remove(engine, "before_cursor_execute", record_character_select) + + assert body["code"] == 200 + assert len(statements) == 1 + + # -- DELETE /projects/{id} --------------------------------------------------- @@ -125,9 +249,9 @@ def test_delete_not_found_returns_404(auth_client): def test_delete_rejected_when_project_has_characters(auth_client): - created = auth_client.post("/projects", json=_payload(project_name="有角色")).json()[ - "data" - ] + created = auth_client.post( + "/projects", json=_payload(project_name="有角色") + ).json()["data"] character = auth_client.post( "/characters", json={ @@ -147,10 +271,14 @@ def test_delete_rejected_when_project_has_characters(auth_client): assert body["message"] == "项目下仍有角色,无法删除" assert body["data"] is None assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200 - assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + assert ( + auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + ) -def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, monkeypatch): +def test_delete_rejected_when_character_arrives_after_empty_check( + auth_client, monkeypatch +): """模拟检查与删除之间插入角色:应用层已看见空项目,数据库仍应拦住删除。""" created = auth_client.post("/projects", json=_payload(project_name="竞态")).json()[ "data" @@ -177,4 +305,6 @@ def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, m assert body["code"] == 400 assert body["message"] == "项目下仍有角色,无法删除" assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200 - assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + assert ( + auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200 + ) diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts index 9c19c0fc..f137bc6e 100644 --- a/frontend/src/entities/project/index.test.ts +++ b/frontend/src/entities/project/index.test.ts @@ -10,6 +10,7 @@ const projectDto = { sprite_height: 96, game_style: null, sprite_sample_url: 'https://cdn.windup.test/style.png', + preview_url: 'https://cdn.windup.test/project-preview.png', create_at: '2026-08-01T08:00:00Z', update_at: '2026-08-02T09:30:00Z', } @@ -61,6 +62,7 @@ describe('projectApis', () => { spriteSize: { width: 64, height: 96 }, gameStyle: null, sampleImageUrl: 'https://cdn.windup.test/style.png', + previewUrl: 'https://cdn.windup.test/project-preview.png', createdAt: '2026-08-01T08:00:00Z', updatedAt: '2026-08-02T09:30:00Z', }, diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 90373565..6c015e58 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -11,6 +11,8 @@ export interface Project { spriteSize: { width: number; height: number } gameStyle: string | null sampleImageUrl: string | null + /** 项目列表已经解析好的卡片预览;详情响应不承诺提供。 */ + previewUrl?: string | null createdAt: string updatedAt: string } @@ -91,6 +93,7 @@ interface ProjectDto { sprite_height: number game_style: string | null sprite_sample_url: string | null + preview_url?: string | null create_at: string update_at: string } @@ -152,6 +155,7 @@ function mapProject(dto: ProjectDto): Project { spriteSize: { width: dto.sprite_width, height: dto.sprite_height }, gameStyle: dto.game_style, sampleImageUrl: dto.sprite_sample_url, + previewUrl: dto.preview_url ?? dto.sprite_sample_url, createdAt: dto.create_at, updatedAt: dto.update_at, } diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 3b5db130..8d74022a 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -4,7 +4,6 @@ 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' @@ -29,14 +28,12 @@ describe('ProjectsPage', () => { vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const response = await backend.fetch(input, init) - if (!request.url.includes('/characters?project_id=42')) return response + if (new URL(request.url).pathname !== '/projects') return response const payload = (await response.json()) as { - data: Array<{ - character_data: { outfits: Array<{ preview_url: string | null }> } - }> + data: Array<{ preview_url: string | null }> } - payload.data[0]!.character_data.outfits[0]!.preview_url = + payload.data[0]!.preview_url = 'https://cdn.windup.test/media/outfit-preview/messenger.source.png' return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' }, @@ -65,171 +62,6 @@ describe('ProjectsPage', () => { }) }) - it('keeps pending project previews distinct from empty projects', async () => { - installBackend() - let releaseRequests: (() => void) | undefined - const requestGate = new Promise((resolve) => { - releaseRequests = resolve - }) - vi.spyOn(characterApis, 'listByProject').mockImplementation(async () => { - await requestGate - return { items: [], total: 0, page: 1, pageSize: 6 } - }) - - render( - - - - - , - ) - - expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) - const loadingPreviews = screen.getAllByRole('status', { name: /正在装载.+项目预览/ }) - expect(loadingPreviews).toHaveLength(2) - expect( - loadingPreviews.every( - (preview) => preview.querySelectorAll('[data-pixel-matrix-dot]').length === 432, - ), - ).toBe(true) - expect( - loadingPreviews.every( - (preview) => preview.querySelector('[data-pixel-matrix-coverage="compact"]') !== null, - ), - ).toBe(true) - expect(screen.queryByText('等待第一份角色资产')).toBeNull() - - releaseRequests?.() - expect( - await screen.findAllByRole('status', { name: /项目预览:等待第一份角色资产/ }), - ).toHaveLength(2) - }) - - it('reveals each completed preview without waiting for the rest of the page', async () => { - installBackend() - let releaseSlowRequest: (() => void) | undefined - const slowRequest = new Promise((resolve) => { - releaseSlowRequest = resolve - }) - vi.spyOn(characterApis, 'listByProject').mockImplementation(async (projectId) => { - if (String(projectId) === '42') await slowRequest - return { items: [], total: 0, page: 1, pageSize: 6 } - }) - - render( - - - - - , - ) - - try { - expect( - await screen.findByRole('status', { - name: '空白海岸的项目预览:等待第一份角色资产', - }), - ).toBeTruthy() - expect(screen.getByRole('status', { name: '正在装载点灯人 · MVP的项目预览' })).toBeTruthy() - } finally { - releaseSlowRequest?.() - } - }) - - it('shows preview request failures instead of presenting the project as empty', async () => { - installBackend() - vi.spyOn(characterApis, 'listByProject').mockRejectedValue(new Error('preview unavailable')) - - render( - - - - - , - ) - - expect( - await screen.findAllByRole('status', { name: /项目预览:预览暂时无法读取/ }), - ).toHaveLength(2) - expect(screen.queryByText('等待第一份角色资产')).toBeNull() - }) - - it('does not call a project empty when characters exist without a usable preview', async () => { - installBackend() - const character = await characterApis.get('51') - vi.spyOn(characterApis, 'listByProject').mockImplementation(async (_projectId, query) => ({ - items: - query?.page === 1 - ? [ - { - ...character, - referenceImageUrl: null, - outfits: character.outfits.map((outfit) => ({ - ...outfit, - previewUrl: null, - actions: outfit.actions.map((action) => ({ ...action, frames: [] })), - })), - }, - ] - : [], - total: 7, - page: query?.page ?? 1, - pageSize: 6, - })) - - render( - - - - - , - ) - - expect(await screen.findAllByText('预览暂时无法读取')).toHaveLength(2) - expect(screen.queryByText('等待第一份角色资产')).toBeNull() - }) - - it('finds a usable preview on a later character page', async () => { - installBackend() - const character = await characterApis.get('51') - const emptyCharacter = { - ...character, - referenceImageUrl: null, - outfits: character.outfits.map((outfit) => ({ - ...outfit, - previewUrl: null, - actions: outfit.actions.map((action) => ({ ...action, frames: [] })), - })), - } - const listSpy = vi - .spyOn(characterApis, 'listByProject') - .mockImplementation(async (projectId, query) => { - if (String(projectId) !== '42') { - return { items: [], total: 0, page: 1, pageSize: 6 } - } - return query?.page === 2 - ? { items: [character], total: 7, page: 2, pageSize: 6 } - : { items: [emptyCharacter], total: 7, page: 1, pageSize: 6 } - }) - - render( - - - - - , - ) - - expect( - (await screen.findByRole('img', { name: '点灯人 · MVP的项目预览' })).getAttribute('src'), - ).toBe('https://cdn.windup.test/messenger-outfit.png') - expect( - listSpy.mock.calls.some( - ([projectId, query]) => String(projectId) === '42' && query?.page === 2, - ), - ).toBe(true) - }) - it('keeps the loading surface until the preview image is decoded', async () => { installBackend() render( @@ -306,27 +138,15 @@ describe('ProjectsPage', () => { 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.every((request) => new URL(request.url).pathname === '/projects')).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) + backend.requests.filter((request) => new URL(request.url).pathname === '/characters'), + ).toHaveLength(0) }) it('sends creation to the project create page and deletes through the Project API', async () => { @@ -379,36 +199,10 @@ describe('ProjectsPage', () => { expect(screen.getByRole('link', { name: '打开项目 点灯人 · MVP' })).toBeTruthy() }) - it('falls back through character preview sources without blocking the gallery', async () => { + it('uses project previews and keeps missing previews in the empty state', 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( @@ -424,114 +218,18 @@ describe('ProjectsPage', () => { .getByRole('link', { name: '打开项目 点灯人 · MVP' }) .querySelector('img') ?.getAttribute('src'), - ).toBe('https://cdn.windup.test/messenger-reference.png') + ).toBe('https://cdn.windup.test/messenger-outfit.png') expect( screen .getByRole('link', { name: '打开项目 空白海岸' }) .querySelector('img') ?.getAttribute('src'), - ).toBe('https://cdn.windup.test/idle-01.png') - expect(screen.getByText('预览暂时无法读取')).toBeTruthy() + ).toBeUndefined() + expect(screen.getAllByText('等待第一份角色资产')).toHaveLength(1) }) - }) - - 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('reuses completed preview results when returning to a project page', async () => { - const backend = createProjectAssetsBackend({ projectCount: 13 }) - vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') - vi.stubGlobal('fetch', backend.fetch) - const listSpy = vi.spyOn(characterApis, 'listByProject').mockResolvedValue({ - items: [], - total: 0, - page: 1, - pageSize: 6, - }) - render( - - - - - , - ) - - await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(12)) - 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) - }) - - it('releases active preview slots when their project page leaves', async () => { - const backend = createProjectAssetsBackend({ projectCount: 13 }) - vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') - vi.stubGlobal('fetch', backend.fetch) - const signals: AbortSignal[] = [] - const releaseRequests: Array<() => void> = [] - const listSpy = vi.spyOn(characterApis, 'listByProject').mockImplementation( - (_projectId, query) => - new Promise((resolve) => { - const signal = query?.signal - if (!signal) throw new Error('项目预览请求缺少取消信号') - signals.push(signal) - releaseRequests.push(() => resolve({ items: [], total: 0, page: 1, pageSize: 6 })) - }), - ) - - render( - - - - - , - ) - - await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(2)) - fireEvent.click(screen.getByRole('button', { name: '下一页' })) - - expect(signals.slice(0, 2).every((signal) => signal.aborted)).toBe(true) - expect(listSpy).toHaveBeenCalledTimes(2) - releaseRequests[0]?.() - releaseRequests[1]?.() - await waitFor(() => expect(listSpy).toHaveBeenCalledTimes(3)) - expect(signals[2]?.aborted).toBe(false) + expect( + backend.requests.filter((request) => new URL(request.url).pathname === '/characters'), + ).toHaveLength(0) }) it('navigates every backend Project page instead of truncating after the first page', async () => { diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index 35a451ef..5c466c1f 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,42 +1,24 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useEffect, useState, type CSSProperties } from 'react' import { Link } from 'react-router' import assetLibraryArtwork from '@/assets/workspace/asset-library.png' -import { - characterApis, - projectApis, - ProjectHasCharactersError, - type Character, - type Project, -} from '@/entities' +import { projectApis, ProjectHasCharactersError, type Project } from '@/entities' import type { Paged } from '@/shared/pagination' import { AssetThumbnailImage, Pagination, PixelMatrix } from '@/shared/ui' const PROJECT_PAGE_SIZE = 12 -const PROJECT_PREVIEW_CHARACTER_LIMIT = 6 -const PROJECT_PREVIEW_REQUEST_CONCURRENCY = 2 -type ResolvedProjectPreview = { status: 'ready'; url: string } | { status: 'empty' } -type ProjectPreviewState = ResolvedProjectPreview | { status: 'loading' } | { status: 'error' } - -interface ProjectPreviewRequest { - projectId: string - state: 'queued' | 'active' - cancelled: boolean - controller: AbortController - promise: Promise - resolve: (preview: ProjectPreviewState) => void -} +/** + * 卡片预览只有两种落点:后端聚合出了图,或这个项目还没有可用素材。 + * 列表响应已经带上 previewUrl,所以不存在"预览正在请求中"这一态—— + * 图片自身的解码等待由 ProjectPreviewImage 内部处理,别和这里混为一谈。 + */ +type ProjectPreviewState = { status: 'ready'; url: string } | { status: 'empty' } /** 项目中心;项目是角色资产与生成规格的隔离边界。 */ 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) @@ -58,133 +40,11 @@ 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 - ? { status: 'ready' as const, url: project.sampleImageUrl } - : (projectPreviewCache.current.get(project.id) ?? { status: 'loading' as const }), - ]), - ) - setProjectPreviews(previews) - const projectsWithoutPreview = projectsPage.items.filter( - (project) => !project.sampleImageUrl && !projectPreviewCache.current.has(project.id), - ) - const requestedProjectIds = new Set(projectsWithoutPreview.map((project) => project.id)) - projectsWithoutPreview.forEach((project) => { - void loadProjectPreview(project.id).then((preview) => { - if (active) setProjectPreviews((current) => ({ ...current, [project.id]: preview })) - }) - }) - - return () => { - active = false - cancelProjectPreviewRequests(requestedProjectIds) - } - }, [projectsPage]) - - function loadProjectPreview(projectId: string): Promise { - const cachedPreview = projectPreviewCache.current.get(projectId) - if (cachedPreview) return Promise.resolve(cachedPreview) - const currentRequest = projectPreviewRequests.current.get(projectId) - if (currentRequest) return currentRequest.promise - - let resolvePreview: (preview: ProjectPreviewState) => void = () => undefined - const promise = new Promise((resolve) => { - resolvePreview = resolve - }) - const request: ProjectPreviewRequest = { - projectId, - state: 'queued', - cancelled: false, - controller: new AbortController(), - 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: ProjectPreviewState = { status: 'error' } - try { - let pageNumber = 1 - while (!request.cancelled) { - const page = await characterApis.listByProject(request.projectId, { - page: pageNumber, - pageSize: PROJECT_PREVIEW_CHARACTER_LIMIT, - signal: request.controller.signal, - }) - const url = previewFromCharacters(page.items) - if (url) { - preview = { status: 'ready', url } - break - } - if (page.total === 0) { - preview = { status: 'empty' } - break - } - if (page.items.length === 0 || page.page * page.pageSize >= page.total) break - pageNumber = page.page + 1 - } - } catch { - preview = { status: 'error' } - } finally { - if (!request.cancelled) { - if (preview.status !== 'error') { - 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 cancelProjectPreviewRequests(projectIds: Set) { - projectIds.forEach((projectId) => { - const request = projectPreviewRequests.current.get(projectId) - if (!request) return - request.cancelled = true - request.controller.abort() - request.resolve({ status: 'loading' }) - projectPreviewRequests.current.delete(projectId) - }) - projectPreviewQueue.current = projectPreviewQueue.current.filter( - (request) => !request.cancelled, - ) - processProjectPreviewQueue() - } - 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 { @@ -240,7 +100,6 @@ export function ProjectsPage() { ) : null} @@ -268,29 +127,6 @@ export function ProjectsPage() { ) } -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 ( onDelete: (project: Project) => void }) { return ( @@ -354,12 +188,7 @@ function ProjectGallery({ onDelete(project)} /> @@ -369,6 +198,11 @@ function ProjectGallery({ ) } +function projectPreview(project: Project): ProjectPreviewState { + const url = project.previewUrl ?? project.sampleImageUrl + return url ? { status: 'ready', url } : { status: 'empty' } +} + function ProjectGalleryTile({ project, preview, @@ -422,16 +256,6 @@ function ProjectPreview({ projectName: string preview: ProjectPreviewState }) { - if (preview.status === 'loading') { - return - } - if (preview.status === 'error') { - return ( - - 预览暂时无法读取 - - ) - } if (preview.status === 'empty') { return ( 等待第一份角色资产 @@ -464,26 +288,19 @@ function ProjectPreviewImage({ projectName, url }: { projectName: string; url: s imageState === 'ready' ? 'project-preview-image-ready' : '' }`} /> - {imageState === 'loading' ? ( - - ) : null} + {imageState === 'loading' ? : null} ) } -function ProjectPreviewLoading({ - projectName, - overlay = false, -}: { - projectName: string - overlay?: boolean -}) { +/** 只盖在待解码的预览图上;列表响应自带 previewUrl,卡片不会整格停在装载态。 */ +function ProjectPreviewLoading({ projectName }: { projectName: string }) { return (
diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts index b16ccc9e..7f1e1dea 100644 --- a/frontend/src/test/project-assets-backend.ts +++ b/frontend/src/test/project-assets-backend.ts @@ -9,6 +9,7 @@ interface ProjectDto { sprite_height: number game_style: string | null sprite_sample_url: string | null + preview_url: string | null create_at: string update_at: string } @@ -24,6 +25,7 @@ const projectDtos: ProjectDto[] = [ sprite_height: 64, game_style: '低饱和像素绘本', sprite_sample_url: null, + preview_url: 'https://cdn.windup.test/messenger-outfit.png', create_at: '2026-08-01T08:00:00Z', update_at: '2026-08-04T10:30:00Z', }, @@ -37,6 +39,7 @@ const projectDtos: ProjectDto[] = [ sprite_height: 128, game_style: null, sprite_sample_url: null, + preview_url: null, create_at: '2026-08-02T08:00:00Z', update_at: '2026-08-03T09:00:00Z', }, @@ -229,6 +232,7 @@ export function createProjectAssetsBackend({ sprite_height: body.sprite_height, game_style: body.game_style ?? null, sprite_sample_url: body.sprite_sample_url ?? null, + preview_url: body.sprite_sample_url ?? null, create_at: '2026-08-06T00:00:00Z', update_at: '2026-08-06T00:00:00Z', } diff --git a/openapi.json b/openapi.json index 17c2391f..619b3972 100644 --- a/openapi.json +++ b/openapi.json @@ -1132,7 +1132,7 @@ "title": "ListResponse[CreditTransactionOut]", "type": "object" }, - "ListResponse_ProjectOut_": { + "ListResponse_ProjectListOut_": { "properties": { "code": { "default": 200, @@ -1143,7 +1143,7 @@ "data": { "description": "业务数据列表", "items": { - "$ref": "#/components/schemas/ProjectOut" + "$ref": "#/components/schemas/ProjectListOut" }, "title": "Data", "type": "array" @@ -1188,7 +1188,7 @@ "type": "integer" } }, - "title": "ListResponse[ProjectOut]", + "title": "ListResponse[ProjectListOut]", "type": "object" }, "ListResponse_WorkflowRunOut_": { @@ -1459,6 +1459,105 @@ "title": "ProjectCreate", "type": "object" }, + "ProjectListOut": { + "description": "项目列表项;预览是读取投影,不写回 Project。", + "properties": { + "character_perspective": { + "title": "Character Perspective", + "type": "integer" + }, + "create_at": { + "format": "date-time", + "title": "Create At", + "type": "string" + }, + "directional_movement": { + "title": "Directional Movement", + "type": "integer" + }, + "game_style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Game Style" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "preview_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Preview Url" + }, + "project_name": { + "title": "Project Name", + "type": "string" + }, + "sprite_height": { + "title": "Sprite Height", + "type": "integer" + }, + "sprite_sample_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sprite Sample Url" + }, + "sprite_width": { + "title": "Sprite Width", + "type": "integer" + }, + "update_at": { + "format": "date-time", + "title": "Update At", + "type": "string" + }, + "workflow_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + "required": [ + "id", + "workflow_id", + "project_name", + "character_perspective", + "directional_movement", + "sprite_width", + "sprite_height", + "game_style", + "sprite_sample_url", + "create_at", + "update_at", + "preview_url" + ], + "title": "ProjectListOut", + "type": "object" + }, "ProjectOut": { "description": "项目响应。", "properties": { @@ -3456,7 +3555,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListResponse_ProjectOut_" + "$ref": "#/components/schemas/ListResponse_ProjectListOut_" } } },