diff --git a/.github/assets/playtest/playtest-entry-desktop.png b/.github/assets/playtest/playtest-entry-desktop.png new file mode 100644 index 00000000..766a2e62 Binary files /dev/null and b/.github/assets/playtest/playtest-entry-desktop.png differ diff --git a/.github/assets/playtest/playtest-entry-mobile.png b/.github/assets/playtest/playtest-entry-mobile.png new file mode 100644 index 00000000..be0f7c97 Binary files /dev/null and b/.github/assets/playtest/playtest-entry-mobile.png differ diff --git a/backend/packages/app/src/windup_app/server/character/interface.py b/backend/packages/app/src/windup_app/server/character/interface.py index 36c4db56..2ccf32ae 100644 --- a/backend/packages/app/src/windup_app/server/character/interface.py +++ b/backend/packages/app/src/windup_app/server/character/interface.py @@ -42,7 +42,12 @@ def get_character_by_workflow_run( @abstractmethod def list_characters( - self, session: Session, *, project_id: int, page: int, page_size: int, + self, + session: Session, + *, + project_id: int, + page: int, + page_size: int, status: int | None = None, ) -> tuple[list[Character], int]: """分页查询项目下的角色列表,返回 (当前页数据, 总数)。 @@ -51,7 +56,21 @@ def list_characters( """ @abstractmethod - def update_character(self, session: Session, character_id: int, **fields) -> Character | None: + def list_characters_for_user( + self, + session: Session, + *, + user_id: int, + page: int, + page_size: int, + status: int | None = None, + ) -> tuple[list[Character], int]: + """分页查询用户全部项目下的角色列表,返回 (当前页数据, 总数)。""" + + @abstractmethod + def update_character( + self, session: Session, character_id: int, **fields + ) -> Character | None: """更新角色描述、参考图或 character_data 等字段。 返回更新后的角色;不存在时返回 ``None``。 diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py index 5a39e9b0..d9f9f465 100644 --- a/backend/packages/app/src/windup_app/server/character/service.py +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -13,6 +13,7 @@ from windup_app.server.character.interface import CharacterService from windup_app.server.character.model import Character +from windup_app.server.project.model import Project class SqlAlchemyCharacterService(CharacterService): @@ -36,20 +37,53 @@ def get_character_by_workflow_run( return session.scalar(stmt) def list_characters( - self, session: Session, *, project_id: int, page: int, page_size: int, + self, + session: Session, + *, + project_id: int, + page: int, + page_size: int, status: int | None = None, ) -> tuple[list[Character], int]: base_condition = Character.project_id == project_id if status is not None: base_condition = base_condition & (Character.status == status) + count_stmt = select(func.count()).select_from(Character).where(base_condition) + stmt = ( + select(Character) + .where(base_condition) + .order_by(Character.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + total = session.scalar(count_stmt) or 0 + items = list(session.scalars(stmt)) + return items, total + + def list_characters_for_user( + self, + session: Session, + *, + user_id: int, + page: int, + page_size: int, + status: int | None = None, + ) -> tuple[list[Character], int]: + base_condition = Project.user_id == user_id + if status is not None: + base_condition = base_condition & (Character.status == status) + + owned_characters = Character.project_id == Project.id count_stmt = ( select(func.count()) .select_from(Character) + .join(Project, owned_characters) .where(base_condition) ) stmt = ( select(Character) + .join(Project, owned_characters) .where(base_condition) .order_by(Character.id.desc()) .offset((page - 1) * page_size) @@ -60,7 +94,10 @@ def list_characters( return items, total def update_character( - self, session: Session, character_id: int, **fields, + self, + session: Session, + character_id: int, + **fields, ) -> Character | None: character = session.get(Character, character_id) if character is None: diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py index 9947c9ed..c335a5e5 100644 --- a/backend/packages/app/src/windup_app/web/api/character.py +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -97,7 +97,9 @@ def _extract_object_keys(character: Character) -> list[str]: def _get_project_or_raise( - session: Session, project_id: int, user_id: int, + session: Session, + project_id: int, + user_id: int, ) -> Project: """校验项目存在且属于当前用户,否则抛 BizException。""" project = session.get(Project, project_id) @@ -107,7 +109,9 @@ def _get_project_or_raise( def _get_character_with_auth( - session: Session, character_id: int, user_id: int, + session: Session, + character_id: int, + user_id: int, ) -> Character: """获取角色并校验其所属项目属于当前用户。 @@ -159,18 +163,33 @@ def create_character( @router.get("", response_model=ListResponse[CharacterOut]) def list_characters( - project_id: int = Query(..., gt=0), + project_id: int | None = Query(None, gt=0), request: Request = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - status: int | None = Query(None, ge=0, le=1, description="按发布状态过滤: 0=草稿, 1=已发布"), + status: int | None = Query( + None, ge=0, le=1, description="按发布状态过滤: 0=草稿, 1=已发布" + ), session: Session = Depends(get_session), ) -> ListResponse[CharacterOut]: user_id = request.state.current_user.id - _get_project_or_raise(session, project_id, user_id) - items, total = character_service.list_characters( - session, project_id=project_id, page=page, page_size=page_size, status=status, - ) + if project_id is None: + items, total = character_service.list_characters_for_user( + session, + user_id=user_id, + page=page, + page_size=page_size, + status=status, + ) + else: + _get_project_or_raise(session, project_id, user_id) + items, total = character_service.list_characters( + session, + project_id=project_id, + page=page, + page_size=page_size, + status=status, + ) return ListResponse.success( [CharacterOut.model_validate(c) for c in items], total=total, diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py index 486a4aba..40738498 100644 --- a/backend/tests/test_character_api.py +++ b/backend/tests/test_character_api.py @@ -6,13 +6,16 @@ def _create_project(auth_client, name: str = "默认项目") -> dict: """创建一个项目并返回响应 data。""" - return auth_client.post("/projects", json={ - "project_name": name, - "character_perspective": 1, - "directional_movement": 2, - "sprite_width": 64, - "sprite_height": 64, - }).json()["data"] + return auth_client.post( + "/projects", + json={ + "project_name": name, + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + }, + ).json()["data"] def _payload(project_id: int, **overrides): @@ -35,17 +38,26 @@ def _payload_with_frames(project_id: int, **overrides): "name": "有帧角色", "description": "包含真实帧", "character_data": { - "outfits": [{ - "id": "outfit-1", - "name": "默认造型", - "actions": [{ - "id": "action-1", - "type": "idle", - "name": "待机", - "frame_count": 1, - "frames": [{"index": 0, "image_url": "https://example.com/frame.png"}], - }], - }], + "outfits": [ + { + "id": "outfit-1", + "name": "默认造型", + "actions": [ + { + "id": "action-1", + "type": "idle", + "name": "待机", + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.com/frame.png", + } + ], + } + ], + } + ], }, } base.update(overrides) @@ -88,7 +100,8 @@ def test_create_name_roundtrip(auth_client): """名称持久化后可通过 GET 读回。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"], name="小精灵"), + "/characters", + json=_payload(project["id"], name="小精灵"), ).json()["data"] resp = auth_client.get(f"/characters/{created['id']}") @@ -123,16 +136,19 @@ def test_create_under_other_users_project_returns_404(auth_client, auth_client_b def test_create_same_workflow_run_under_another_project_returns_404( - auth_client, auth_client_b, + auth_client, + auth_client_b, ): project_a = _create_project(auth_client, "用户 A 项目") project_b = _create_project(auth_client_b, "用户 B 项目") created = auth_client.post( - "/characters", json=_payload(project_a["id"], workflow_run_id=42), + "/characters", + json=_payload(project_a["id"], workflow_run_id=42), ).json()["data"] resp = auth_client_b.post( - "/characters", json=_payload(project_b["id"], workflow_run_id=42), + "/characters", + json=_payload(project_b["id"], workflow_run_id=42), ) assert resp.json()["code"] == 404 @@ -155,7 +171,8 @@ def test_get_other_users_character_returns_404(auth_client, auth_client_b): """用户 B 不能查看用户 A 的角色。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] resp = auth_client_b.get(f"/characters/{created['id']}") @@ -168,11 +185,13 @@ def test_update_other_users_character_returns_404(auth_client, auth_client_b): """用户 B 不能修改用户 A 的角色。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] resp = auth_client_b.patch( - f"/characters/{created['id']}", json={"name": "黑化"}, + f"/characters/{created['id']}", + json={"name": "黑化"}, ) assert resp.json()["code"] == 404 @@ -183,7 +202,8 @@ def test_delete_other_users_character_returns_404(auth_client, auth_client_b): """用户 B 不能删除用户 A 的角色。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] resp = auth_client_b.delete(f"/characters/{created['id']}") @@ -219,10 +239,14 @@ def test_list_characters_filter_by_status(auth_client): # 创建草稿角色 auth_client.post("/characters", json=_payload(project["id"], workflow_run_id=1)) # 创建已发布角色 - auth_client.post("/characters", json=_payload_with_frames(project["id"], workflow_run_id=2)) + auth_client.post( + "/characters", json=_payload_with_frames(project["id"], workflow_run_id=2) + ) # 查询已发布角色 - resp = auth_client.get("/characters", params={"project_id": project["id"], "status": 1}) + resp = auth_client.get( + "/characters", params={"project_id": project["id"], "status": 1} + ) data = resp.json() assert data["code"] == 200 assert data["total"] == 1 @@ -230,7 +254,9 @@ def test_list_characters_filter_by_status(auth_client): assert data["data"][0]["status"] == 1 # 查询草稿角色 - resp = auth_client.get("/characters", params={"project_id": project["id"], "status": 0}) + resp = auth_client.get( + "/characters", params={"project_id": project["id"], "status": 0} + ) data = resp.json() assert data["code"] == 200 assert data["total"] == 1 @@ -242,7 +268,9 @@ def test_list_characters_without_status_returns_all(auth_client): """不传 status 参数时返回所有角色。""" project = _create_project(auth_client) auth_client.post("/characters", json=_payload(project["id"], workflow_run_id=1)) - auth_client.post("/characters", json=_payload_with_frames(project["id"], workflow_run_id=2)) + auth_client.post( + "/characters", json=_payload_with_frames(project["id"], workflow_run_id=2) + ) resp = auth_client.get("/characters", params={"project_id": project["id"]}) data = resp.json() @@ -251,11 +279,53 @@ def test_list_characters_without_status_returns_all(auth_client): assert len(data["data"]) == 2 +def test_list_characters_without_project_returns_only_current_users_projects( + auth_client, + auth_client_b, +): + """跨项目列表必须完整分页,同时保持用户归属边界。""" + project_a1 = _create_project(auth_client, "用户 A 项目一") + project_a2 = _create_project(auth_client, "用户 A 项目二") + project_b = _create_project(auth_client_b, "用户 B 项目") + created_a1 = auth_client.post( + "/characters", + json=_payload(project_a1["id"], workflow_run_id=101, name="角色一"), + ).json()["data"] + created_a2 = auth_client.post( + "/characters", + json=_payload_with_frames(project_a2["id"], workflow_run_id=102, name="角色二"), + ).json()["data"] + auth_client_b.post( + "/characters", + json=_payload(project_b["id"], workflow_run_id=201, name="他人角色"), + ) + + first = auth_client.get("/characters", params={"page": 1, "page_size": 1}).json() + second = auth_client.get("/characters", params={"page": 2, "page_size": 1}).json() + + assert first["code"] == 200 + assert first["total"] == 2 + assert second["total"] == 2 + assert [first["data"][0]["id"], second["data"][0]["id"]] == [ + created_a2["id"], + created_a1["id"], + ] + assert {first["data"][0]["project_id"], second["data"][0]["project_id"]} == { + project_a1["id"], + project_a2["id"], + } + + published = auth_client.get("/characters", params={"status": 1}).json() + assert published["total"] == 1 + assert [item["id"] for item in published["data"]] == [created_a2["id"]] + + def test_update_character_data_recalculates_status(auth_client): """更新 character_data 后应自动重新计算 status。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload(project["id"]), + "/characters", + json=_payload(project["id"]), ).json()["data"] # 初始为草稿 @@ -266,17 +336,26 @@ def test_update_character_data_recalculates_status(auth_client): f"/characters/{created['id']}", json={ "character_data": { - "outfits": [{ - "id": "outfit-1", - "name": "默认造型", - "actions": [{ - "id": "action-1", - "type": "idle", - "name": "待机", - "frame_count": 1, - "frames": [{"index": 0, "image_url": "https://example.com/frame.png"}], - }], - }], + "outfits": [ + { + "id": "outfit-1", + "name": "默认造型", + "actions": [ + { + "id": "action-1", + "type": "idle", + "name": "待机", + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.com/frame.png", + } + ], + } + ], + } + ], }, }, ) @@ -288,7 +367,8 @@ def test_update_character_with_null_character_data(auth_client): """更新 character_data 为 null 时应返回 400 错误。""" project = _create_project(auth_client) created = auth_client.post( - "/characters", json=_payload_with_frames(project["id"]), + "/characters", + json=_payload_with_frames(project["id"]), ).json()["data"] # 更新 character_data 为 null diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index c11ba8d6..16b3db40 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -151,6 +151,29 @@ describe('characterApis', () => { }) }) + it('lists current-user Characters without a per-project query', async () => { + let requestUrl = '' + const characterApis = await loadCharacterApis(async (input) => { + requestUrl = String(input) + return new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [characterDto], + total: 1, + page: 2, + page_size: 100, + }), + { headers: { 'content-type': 'application/json' } }, + ) + }) + + const page = await characterApis.list({ page: 2, pageSize: 100 }) + + expect(requestUrl).toBe('https://api.windup.test/characters?page=2&page_size=100') + expect(page.items[0]?.projectId).toBe('42') + }) + it('serializes CreateCharacterInput without inventing generated assets', async () => { let request: Request | undefined const characterApis = await loadCharacterApis(async (input, init) => { diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index df962658..a5258764 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -75,6 +75,7 @@ export interface CreateCharacterInput { */ export interface CharacterApis { get(id: Character['id']): Promise + list(query?: CharacterPageQuery): Promise> listByProject(projectId: string, query?: CharacterPageQuery): Promise> create(input: CreateCharacterInput): Promise update(character: Character): Promise @@ -224,6 +225,17 @@ export const characterApis: CharacterApis = { ) }, + async list(query = {}) { + const result = await getApiClient().requestList('/characters', { + query: { + page: query.page, + page_size: query.pageSize, + status: query.status, + }, + }) + return { ...result, items: result.items.map(mapCharacter) } + }, + async listByProject(projectId, query = {}) { const result = await getApiClient().requestList('/characters', { query: { diff --git a/frontend/src/pages/asset-library/index.test.tsx b/frontend/src/pages/asset-library/index.test.tsx index 58cb1bde..489a8140 100644 --- a/frontend/src/pages/asset-library/index.test.tsx +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -40,7 +40,7 @@ describe('AssetLibraryPage', () => { expect(preview.getAttribute('decoding')).toBe('async') expect(preview.getAttribute('fetchpriority')).toBe('high') expect(screen.getAllByText('1 套造型')).toHaveLength(1) - expect(screen.getByText('2 个动作')).toBeTruthy() + expect(screen.getByText('3 个动作')).toBeTruthy() expect(screen.queryByRole('searchbox')).toBeNull() expect(screen.queryByRole('button', { name: '导出全部角色资产' })).toBeNull() }) diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 63e0b3c5..c114c12f 100644 --- a/frontend/src/pages/character-detail/index.test.tsx +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -32,7 +32,7 @@ describe('CharacterDetailPage', () => { expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() expect(screen.getByRole('combobox', { name: '选择造型' })).toBeTruthy() - expect(screen.getAllByRole('article', { name: /动作/ })).toHaveLength(2) + expect(screen.getAllByRole('article', { name: /动作/ })).toHaveLength(3) expect(screen.getByRole('img', { name: '呼吸待机帧预览' }).getAttribute('src')).toBe( 'https://cdn.windup.test/idle-01.png', ) diff --git a/frontend/src/pages/playtest/entry.test.tsx b/frontend/src/pages/playtest/entry.test.tsx index 07e99c78..4b6a8ff2 100644 --- a/frontend/src/pages/playtest/entry.test.tsx +++ b/frontend/src/pages/playtest/entry.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -39,20 +39,38 @@ describe('PlaytestEntryPage', () => { it('links playable outfits to their concrete Playtest route', async () => { renderEntry() - expect(await screen.findByRole('heading', { name: '选择可预览资产' })).toBeTruthy() - expect(screen.getByTestId('playtest-pixel-stage').getAttribute('aria-hidden')).toBe('true') + expect(await screen.findByRole('heading', { name: '预览台' })).toBeTruthy() + expect(screen.queryByTestId('playtest-pixel-stage')).toBeNull() expect( (await screen.findByRole('link', { name: '预览 轻装信使 · 常态造型' })).getAttribute('href'), ).toBe('/playtest/51/outfit-default') + expect(screen.getAllByText('点灯人 · MVP').length).toBeGreaterThan(0) + expect(screen.getByText('呼吸待机 · 行走')).toBeTruthy() expect(screen.getByText('2 个动作 · 5 帧')).toBeTruthy() expect(screen.getByText('尚无可播放帧')).toBeTruthy() }) + it('filters the global outfit gallery by project without starting a second picker flow', async () => { + renderEntry() + + expect(await screen.findByRole('button', { name: '筛选项目 空白海岸' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '筛选项目 空白海岸' })) + + expect(screen.queryByRole('link', { name: '预览 轻装信使 · 常态造型' })).toBeNull() + expect(screen.getByText('这个项目还没有可预览造型')).toBeTruthy() + expect(screen.getByRole('button', { name: '筛选全部项目' }).getAttribute('aria-pressed')).toBe( + 'false', + ) + }) + it('directs an empty account back to character creation', async () => { renderEntry(0) expect(await screen.findByText('还没有可预览的角色')).toBeTruthy() - expect(screen.getByRole('link', { name: '开始创作' }).getAttribute('href')).toBe('/quick-start') + const createLink = screen.getByRole('link', { name: '开始创作' }) + expect(createLink.getAttribute('href')).toBe('/quick-start') + expect(createLink.getAttribute('data-ui')).toBe('editorial-entry-card') + expect(createLink.querySelector('img')?.getAttribute('src')).toContain('playtest.png') expect(screen.getByRole('link', { name: '查看项目资产' }).getAttribute('href')).toBe( '/projects', ) @@ -65,7 +83,7 @@ describe('PlaytestEntryPage', () => { expect(screen.queryByText('还没有可预览的角色')).toBeNull() }) - it('loads every project and character page before presenting the asset count', async () => { + it('loads every project and character page without per-project requests', async () => { const backend = createProjectAssetsBackend({ projectCount: 101, characterCount: 101 }) renderEntryWith(backend.fetch) @@ -76,15 +94,16 @@ describe('PlaytestEntryPage', () => { return url.pathname === '/projects' && url.searchParams.get('page') === '2' }), ).toBe(true) + const characterRequests = backend.requests.filter( + (request) => new URL(request.url).pathname === '/characters', + ) expect( - backend.requests.some((request) => { - const url = new URL(request.url) - return ( - url.pathname === '/characters' && - url.searchParams.get('project_id') === '42' && - url.searchParams.get('page') === '2' - ) - }), + characterRequests.map((request) => new URL(request.url).searchParams.get('page')), + ).toEqual(['1', '2']) + expect( + characterRequests.every( + (request) => new URL(request.url).searchParams.get('project_id') === null, + ), ).toBe(true) }) }) diff --git a/frontend/src/pages/playtest/entry.tsx b/frontend/src/pages/playtest/entry.tsx index 0805acbd..74921de9 100644 --- a/frontend/src/pages/playtest/entry.tsx +++ b/frontend/src/pages/playtest/entry.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router' import { @@ -10,9 +10,7 @@ import { type Project, } from '@/entities' import type { Paged } from '@/shared/pagination' -import { PageContainer } from '@/shared/ui' - -import { PlaytestPixelStage } from './pixel-stage' +import { EditorialEntryCard, PageContainer } from '@/shared/ui' interface ProjectCharacters { project: Project @@ -48,21 +46,21 @@ function characterName(character: Character) { */ export function PlaytestEntryPage() { const [state, setState] = useState(initialState) + const [selectedProjectId, setSelectedProjectId] = useState(null) useEffect(() => { let active = true setState(initialState) - void loadAllPages((page) => projectApis.list({ page, pageSize: ASSET_PAGE_SIZE })) - .then(async (projects) => - Promise.all( - projects.map(async (project) => ({ - project, - characters: await loadAllPages((page) => - characterApis.listByProject(project.id, { page, pageSize: ASSET_PAGE_SIZE }), - ), - })), - ), + void Promise.all([ + loadAllPages((page) => projectApis.list({ page, pageSize: ASSET_PAGE_SIZE })), + loadAllPages((page) => characterApis.list({ page, pageSize: ASSET_PAGE_SIZE })), + ]) + .then(([projects, characters]) => + projects.map((project) => ({ + project, + characters: characters.filter((character) => character.projectId === project.id), + })), ) .then( (groups) => { @@ -78,39 +76,38 @@ export function PlaytestEntryPage() { } }, []) - const outfitCount = - state.groups?.reduce( - (total, group) => - total + group.characters.reduce((sum, character) => sum + character.outfits.length, 0), - 0, - ) ?? 0 + const outfits = useMemo( + () => + state.groups?.flatMap(({ project, characters }) => + characters.flatMap((character) => + character.outfits.map((outfit) => ({ project, character, outfit })), + ), + ) ?? [], + [state.groups], + ) + const visibleOutfits = selectedProjectId + ? outfits.filter(({ project }) => project.id === selectedProjectId) + : outfits + const outfitCount = outfits.length return (
-
-
-

- Character field test +

+
+

+ 预览台 +

+

+ 从已有造型进入操控测试,检查动作衔接、移动反馈和实际播放效果。

-
-

- 选择可预览资产 -

-

- 选择一套已有造型,检查动作衔接、移动反馈和实际播放效果。 -

-
-
- - {state.groups !== null ? `${outfitCount} 套造型已接入` : '正在接入资产'} -
- - +

+ {state.groups !== null ? `${outfitCount} 套造型已接入` : '正在接入资产'} +

{state.error ? ( @@ -120,43 +117,59 @@ export function PlaytestEntryPage() { ) : outfitCount === 0 ? ( ) : ( -
- {state.groups.map((group) => { - const charactersWithOutfits = group.characters.filter( - (character) => character.outfits.length > 0, - ) - if (charactersWithOutfits.length === 0) return null +
+
+ + {state.groups.map(({ project }) => ( + + ))} +
- return ( -
-
-

- {group.project.name} -

- - 查看项目资产 - -
-
- {charactersWithOutfits.flatMap((character) => - character.outfits.map((outfit) => ( - - )), - )} -
-
- ) - })} + {visibleOutfits.length === 0 ? ( +
+

这个项目还没有可预览造型

+

可以先去项目资产确认角色和造型。

+
+ ) : ( +
+ {visibleOutfits.map(({ project, character, outfit }) => ( + + ))} +
+ )}
)}
@@ -164,12 +177,22 @@ export function PlaytestEntryPage() { ) } -function OutfitCard({ character, outfit }: { character: Character; outfit: Outfit }) { +function OutfitCard({ + project, + character, + outfit, +}: { + project: Project + character: Character + outfit: Outfit +}) { const { frameCount, playable } = getOutfitPlayback(outfit) const name = characterName(character) + const playableActions = outfit.actions.filter((action) => action.frames.length > 0) + const actionSummary = playableActions.map((action) => action.name).join(' · ') const content = (
-
-

{name}

+
+

{project.name}

-

- {outfit.name} -

+
+

+ {name} +

+

{outfit.name}

+
-

- {playable ? `${outfit.actions.length} 个动作 · ${frameCount} 帧` : '尚无可播放帧'} -

+
+ {playable ? ( + <> +

{actionSummary}

+

+ {playableActions.length} 个动作 · {frameCount} 帧 +

+ + ) : ( +

尚无可播放帧

+ )} +
) @@ -226,7 +261,7 @@ function OutfitCard({ character, outfit }: { character: Character; outfit: Outfi {content} @@ -235,23 +270,19 @@ function OutfitCard({ character, outfit }: { character: Character; outfit: Outfi function EmptyState() { return ( -
-

- 还没有可预览的角色 -

-

- 完成角色与动作制作后,可以在这里检查移动和动画效果。 -

-
- - 开始创作 - +
+ +
查看项目资产 diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 1f9036be..fab992cb 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -4,11 +4,13 @@ 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' afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllEnvs() vi.unstubAllGlobals() }) @@ -22,8 +24,8 @@ function installBackend() { describe('ProjectsPage', () => { it('renders backend Projects as the first browsing level', async () => { - installBackend() - const { container } = render( + const backend = installBackend() + render( @@ -32,13 +34,52 @@ describe('ProjectsPage', () => { ) expect(await screen.findByRole('heading', { name: '项目中心' })).toBeTruthy() + const createLink = await screen.findByRole('link', { name: '新建项目' }) + expect(createLink.getAttribute('data-ui')).toBe('editorial-entry-card') + const artwork = createLink.querySelector('img') + expect(artwork).toBeTruthy() + if (!artwork) throw new Error('新建项目入口缺少资产装饰图') + expect(artwork.getAttribute('src')).toContain('asset-library.png') + expect(artwork.getAttribute('aria-hidden')).toBe('true') + expect(screen.queryByText('新的资产空间')).toBeNull() + expect(screen.queryByText('按最近更新排列')).toBeNull() + expect(screen.getAllByRole('link', { name: '新建项目' })).toHaveLength(1) + expect(createLink.getAttribute('href')).toBe('/projects/new') expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) - expect(screen.getByRole('link', { name: '打开项目 点灯人 · MVP' }).getAttribute('href')).toBe( - '/projects/42/assets', + const previewProject = screen.getByRole('link', { name: '打开项目 点灯人 · MVP' }) + expect(previewProject.getAttribute('href')).toBe('/projects/42/assets') + expect(screen.getByRole('heading', { name: '最近项目 · 02' })).toBeTruthy() + expect(previewProject.querySelector('img')?.getAttribute('src')).toBe( + 'https://cdn.windup.test/messenger-outfit.png', ) - expect(screen.getByText('低饱和像素绘本')).toBeTruthy() - expect(container.querySelectorAll('[data-project-card]')).toHaveLength(2) + const emptyProject = screen.getByRole('link', { name: '打开项目 空白海岸' }) + expect(emptyProject.querySelector('img')).toBeNull() + expect(emptyProject.textContent).toContain('等待第一份角色资产') + expect(previewProject.textContent).toContain('08/04') + 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.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') === '1' + }), + ).toBe(true) }) it('sends creation to the project create page and deletes through the Project API', async () => { @@ -70,6 +111,52 @@ describe('ProjectsPage', () => { ).toBe(true) }) + it('falls back through character preview sources without blocking the gallery', 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') + return { + items: [ + { + ...character, + referenceImageUrl: Number(projectId) === 42 ? character.referenceImageUrl : null, + outfits: character.outfits.map((outfit) => ({ ...outfit, previewUrl: null })), + }, + ], + total: 1, + page: 1, + pageSize: 1, + } + }) + render( + + + + + , + ) + + expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(3) + await waitFor(() => { + expect( + screen + .getByRole('link', { name: '打开项目 点灯人 · MVP' }) + .querySelector('img') + ?.getAttribute('src'), + ).toBe('https://cdn.windup.test/messenger-reference.png') + expect( + screen + .getByRole('link', { name: '打开项目 空白海岸' }) + .querySelector('img') + ?.getAttribute('src'), + ).toBe('https://cdn.windup.test/idle-01.png') + expect(screen.getByText('等待第一份角色资产')).toBeTruthy() + }) + }) + it('navigates every backend Project page instead of truncating after the first page', async () => { const backend = createProjectAssetsBackend({ projectCount: 13 }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index d549893a..d5e5caba 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useState, type CSSProperties } from 'react' import { Link } from 'react-router' -import { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis, type Project } from '@/entities' +import { characterApis, projectApis, type Character, type Project } from '@/entities' import type { Paged } from '@/shared/pagination' -import { PageContainer, Pagination } from '@/shared/ui' +import { EditorialEntryCard, Pagination } from '@/shared/ui' const PROJECT_PAGE_SIZE = 12 @@ -11,6 +11,7 @@ const PROJECT_PAGE_SIZE = 12 export function ProjectsPage() { const [pageNumber, setPageNumber] = useState(1) const [projectsPage, setProjectsPage] = useState | null>(null) + const [projectPreviews, setProjectPreviews] = useState>({}) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) @@ -32,6 +33,35 @@ export function ProjectsPage() { } }, [pageNumber]) + useEffect(() => { + let active = true + if (!projectsPage) + return () => { + active = false + } + + setProjectPreviews( + Object.fromEntries(projectsPage.items.map((project) => [project.id, project.sampleImageUrl])), + ) + const projectsWithoutPreview = projectsPage.items.filter((project) => !project.sampleImageUrl) + void Promise.all( + projectsWithoutPreview.map(async (project) => { + try { + const page = await characterApis.listByProject(project.id, { page: 1, pageSize: 1 }) + return [project.id, previewFromCharacter(page.items[0])] as const + } catch { + return [project.id, null] as const + } + }), + ).then((entries) => { + if (active) setProjectPreviews((current) => ({ ...current, ...Object.fromEntries(entries) })) + }) + + return () => { + active = false + } + }, [projectsPage]) + async function deleteProject(project: Project) { setDeleting(true) setError(null) @@ -59,30 +89,18 @@ export function ProjectsPage() { } return ( - +
-
-
-

- 项目中心 -

-

- 项目隔离角色资产与生成规格;先选项目,再管理其资产。 -

-
- +

- + 新建项目 - + 项目中心 +

+

+ 项目隔离角色资产与生成规格;先选项目,再管理其资产。 +

{error ? ( @@ -94,23 +112,17 @@ export function ProjectsPage() {

) : projectsPage === null ? (

正在读取项目…

- ) : projectsPage.total === 0 ? ( -
-

还没有项目

-

- 从右上角新建一个项目,之后它会显示在这里。 -

-
) : ( -
- {projectsPage.items.map((project, index) => ( - setDeleteTarget(project)} +
+ + {projectsPage.items.length > 0 ? ( + - ))} + ) : null}
)} {projectsPage ? ( @@ -131,16 +143,82 @@ export function ProjectsPage() { onConfirm={() => deleteProject(deleteTarget)} /> ) : null} - +
) } -function ProjectCard({ +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 ProjectCreateCard() { + return ( + + ) +} + +function ProjectGallery({ + projects, + total, + previews, + onDelete, +}: { + projects: Project[] + total: number + previews: Record + onDelete: (project: Project) => void +}) { + return ( +
+
+ +
+
+ {projects.map((project, index) => ( + onDelete(project)} + /> + ))} +
+
+ ) +} + +function ProjectGalleryTile({ project, + previewUrl, motionOrder, onDelete, }: { project: Project + previewUrl: string | null motionOrder: number onDelete: () => void }) { @@ -151,48 +229,49 @@ function ProjectCard({ return (
-
-

- {project.name} -

-

更新于 {updatedAt}

-
-
-
视角 / 朝向
-
- {CHARACTER_PERSPECTIVE[project.perspective]} ·{' '} - {DIRECTIONAL_MOVEMENT[project.directionalMovement]} -
-
-
-
精灵尺寸
-
- {project.spriteSize.width} × {project.spriteSize.height} -
-
-
-
画风约束
-
- {project.gameStyle ?? '尚未设定'} -
+
+ {previewUrl ? ( + {`${project.name}的项目预览`} + ) : ( +
+ -
+ )} +
+
+

{project.name}

+ {updatedAt}
diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index 1c12ca76..e85d8e32 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -109,6 +109,12 @@ function mutableCharacterApis( ): CharacterApis { return { get: vi.fn(async () => structuredClone(read())), + list: vi.fn(async () => ({ + items: [structuredClone(read())], + total: 1, + page: 1, + pageSize: 20, + })), listByProject: vi.fn(async () => ({ items: [structuredClone(read())], total: 1, diff --git a/frontend/src/pages/workspace/index.test.tsx b/frontend/src/pages/workspace/index.test.tsx index b62cffef..d98541f6 100644 --- a/frontend/src/pages/workspace/index.test.tsx +++ b/frontend/src/pages/workspace/index.test.tsx @@ -233,6 +233,7 @@ describe('WorkspacePage', () => { renderWorkspace() expect(screen.getByRole('heading', { name: '工作台' })).toBeTruthy() + expect(screen.getByText('从这里开始,去任何地方')).toBeTruthy() expect(screen.getByRole('link', { name: '进入快速开始' }).getAttribute('href')).toBe( '/quick-start', ) diff --git a/frontend/src/pages/workspace/index.tsx b/frontend/src/pages/workspace/index.tsx index 659728d2..25781cc0 100644 --- a/frontend/src/pages/workspace/index.tsx +++ b/frontend/src/pages/workspace/index.tsx @@ -352,6 +352,7 @@ export function WorkspacePage() {

工作台

+

从这里开始,去任何地方

@@ -826,7 +827,7 @@ function OutfitSelection({ {playback.playable - ? `${outfit.actions.length} 个动作 · ${playback.frameCount} 帧` + ? `${outfit.actions.filter((action) => action.frames.length > 0).length} 个动作 · ${playback.frameCount} 帧` : '尚无可播放帧'} diff --git a/frontend/src/shared/ui/editorial-entry-card.tsx b/frontend/src/shared/ui/editorial-entry-card.tsx new file mode 100644 index 00000000..e9a93776 --- /dev/null +++ b/frontend/src/shared/ui/editorial-entry-card.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router' + +import assetLibraryArtwork from '@/assets/workspace/asset-library.png' +import playtestArtwork from '@/assets/workspace/playtest.png' + +export type EditorialEntryArtwork = 'asset-library' | 'playtest' + +export interface EditorialEntryCardProps { + action: ReactNode + ariaLabel: string + artwork: EditorialEntryArtwork + description: ReactNode + title: ReactNode + to: string +} + +/** 产品内容页共用的横向入口卡;右侧像素物件只负责提示入口语义。 */ +export function EditorialEntryCard({ + action, + ariaLabel, + artwork, + description, + title, + to, +}: EditorialEntryCardProps) { + const artworkUrl = artwork === 'asset-library' ? assetLibraryArtwork : playtestArtwork + + return ( + +
+

+ {title} +

+

{description}

+ + {action} + +
+
+ +
+ + ) +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 8aeaf703..b1b7dfbb 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,3 +1,5 @@ +export { EditorialEntryCard } from './editorial-entry-card' +export type { EditorialEntryArtwork, EditorialEntryCardProps } from './editorial-entry-card' export { PageContainer } from './page-container' export type { PageContainerProps } from './page-container' export { Pagination } from './pagination' diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts index b16ccc9e..d783e39a 100644 --- a/frontend/src/test/project-assets-backend.ts +++ b/frontend/src/test/project-assets-backend.ts @@ -104,6 +104,15 @@ const characterDtos = [ }, ], }, + { + id: 'attack-draft', + type: 'attack', + name: '未完成攻击', + loop: false, + fps: 10, + frame_count: 0, + frames: [], + }, ], }, ], @@ -248,13 +257,14 @@ export function createProjectAssetsBackend({ } if (request.method === 'GET' && url.pathname === '/characters') { - const projectId = Number(url.searchParams.get('project_id')) + const projectId = url.searchParams.get('project_id') const page = Number(url.searchParams.get('page') ?? 1) const pageSize = Number(url.searchParams.get('page_size') ?? 20) const status = url.searchParams.get('status') const projectCharacters = characters.filter( (item) => - item.project_id === projectId && (status === null || item.status === Number(status)), + (projectId === null || item.project_id === Number(projectId)) && + (status === null || item.status === Number(status)), ) const start = (page - 1) * pageSize return listResponse(