From 13ca792f0b0d01922ad9f124a89221cbaba179c3 Mon Sep 17 00:00:00 2001 From: huyanxius <178478063+huyanxius@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:06:09 +0000 Subject: [PATCH 1/2] perf(projects): eliminate preview request waterfall Rebuild the conflict-resolved PR content directly on the current main base. --- .../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 | 324 +----------------- frontend/src/pages/projects/index.tsx | 219 +----------- frontend/src/test/project-assets-backend.ts | 4 + openapi.json | 107 +++++- 10 files changed, 385 insertions(+), 530 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 2de58ccd..3f7d1309 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' @@ -23,171 +22,6 @@ function installBackend() { } 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( @@ -264,27 +98,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 () => { @@ -337,36 +159,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( @@ -382,114 +178,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 5edf6ff4..241f85e5 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 { 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 ( 等待第一份角色资产 @@ -462,26 +286,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 31e5dfad..68cad473 100644 --- a/openapi.json +++ b/openapi.json @@ -1006,7 +1006,7 @@ "title": "ListResponse[CreditTransactionOut]", "type": "object" }, - "ListResponse_ProjectOut_": { + "ListResponse_ProjectListOut_": { "properties": { "code": { "default": 200, @@ -1017,7 +1017,7 @@ "data": { "description": "业务数据列表", "items": { - "$ref": "#/components/schemas/ProjectOut" + "$ref": "#/components/schemas/ProjectListOut" }, "title": "Data", "type": "array" @@ -1062,7 +1062,7 @@ "type": "integer" } }, - "title": "ListResponse[ProjectOut]", + "title": "ListResponse[ProjectListOut]", "type": "object" }, "ListResponse_WorkflowRunOut_": { @@ -1333,6 +1333,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": { @@ -3268,7 +3367,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListResponse_ProjectOut_" + "$ref": "#/components/schemas/ListResponse_ProjectListOut_" } } }, 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 2/2] 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_" } } },