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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frontend/src/app/app.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import { App } from './app'

afterEach(() => {
cleanup()
window.history.replaceState({}, '', '/')
})

describe('App Playtest route', () => {
it('routes /playtest to the standalone workbench entry without the site navigation', () => {
window.history.replaceState({}, '', '/playtest')

render(<App />)

expect(screen.getByRole('heading', { name: 'Playtest' })).toBeTruthy()
expect(screen.queryByRole('navigation')).toBeNull()
expect(screen.queryByText('项目')).toBeNull()
})
})
29 changes: 28 additions & 1 deletion frontend/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,53 @@
import { useMemo } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router'

import { createCharacterApis, createPlaytestInspectionApis, createProjectApis } from '@/entities'
import { AssetLibraryPage } from '@/pages/asset-library'
import { HomePage } from '@/pages/home'
import { NotFoundPage } from '@/pages/not-found'
import { PlaytestPage } from '@/pages/playtest'
import { PlaytestEntryPage } from '@/pages/playtest/entry'
import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
import { WorkflowEditorPage } from '@/pages/workflow-editor'
import { AppShellRoute } from './layout'

/**
* 详情页使用后端适配器读取角色、项目画布和当前核验结论。
* 适配器只在路由边界组装一次,Playtest 页面不需要知道 HTTP 客户端的创建方式。
*/
function PlaytestFromBackend() {
const apis = useMemo(
() => ({
characters: createCharacterApis(),
projects: createProjectApis(),
inspections: createPlaytestInspectionApis(),
}),
[],
)

return <PlaytestPage apis={apis} />
}

/**
* 路由表与全局外壳。
* 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
* 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。
*/
export function App() {
const playtestApis = useMemo(
() => ({ projects: createProjectApis(), characters: createCharacterApis() }),
[],
)

return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
{/* Playtest 是独立工作台:根入口和详情页都不显示站点目录栏。 */}
<Route path="/playtest" element={<PlaytestEntryPage apis={playtestApis} />} />
<Route path="/playtest/:characterId/:outfitId" element={<PlaytestFromBackend />} />
<Route element={<AppShellRoute />}>
<Route path="/quick-start" element={<QuickStartPage />} />
<Route path="/quick-start/:runId" element={<QuickStartPage />} />
Expand All @@ -28,7 +56,6 @@ export function App() {
<Route path="/projects/:projectId/assets" element={<AssetLibraryPage />} />
<Route path="/workflow-editor/:runId" element={<WorkflowEditorPage />} />
<Route path="/workflow-editor/:runId/:stage" element={<WorkflowEditorPage />} />
<Route path="/playtest/:characterId/:outfitId" element={<PlaytestPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/app/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export function AppShell({ children }: AppShellProps) {
<Link to="/projects" className="hover:text-slate-900">
项目
</Link>
<Link to="/playtest" className="hover:text-slate-900">
Playtest
</Link>
</div>
</nav>
{/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */}
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/entities/character/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { createCharacterApis } from './api'

afterEach(() => {
vi.unstubAllGlobals()
})

describe('character API adapter', () => {
it('preserves an action loop flag when saving the complete character tree', async () => {
const backendCharacter = {
id: 25,
project_id: 3,
description: null,
reference_image_url: null,
status: 1,
character_data: {
version: 1,
outfits: [
{
id: 'outfit-default',
name: 'Default',
description: null,
preview_url: null,
actions: [
{
id: 'idle',
type: 'idle',
name: 'Idle',
loop: true,
fps: 8,
frame_count: 1,
frames: [
{ index: 0, image_url: '/idle-0.png', duration_ms: 125, root_motion: null },
],
},
],
},
],
},
}
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(backendCharacter))
.mockResolvedValueOnce(jsonResponse(backendCharacter))
vi.stubGlobal('fetch', fetchMock)

const apis = createCharacterApis()
const character = await apis.get('25')
await apis.update(character)

expect(character.outfits[0]?.actions[0]?.loop).toBe(true)
const updateRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
const updateBody = JSON.parse(String(updateRequest.body)) as {
character_data: { outfits: Array<{ actions: Array<{ loop: boolean }> }> }
}
expect(updateBody.character_data.outfits[0]?.actions[0]?.loop).toBe(true)
})

it('loads every character page for a project instead of truncating after 100 items', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
listResponse(
Array.from({ length: 100 }, (_, index) => character(index + 1)),
101,
1,
100,
),
)
.mockResolvedValueOnce(listResponse([character(101)], 101, 2, 100))
vi.stubGlobal('fetch', fetchMock)

const result = await createCharacterApis().listByProject('3')

expect(result).toHaveLength(101)
expect(result[0]?.id).toBe('1')
expect(result[100]?.id).toBe('101')
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'http://127.0.0.1:8000/characters?project_id=3&page=1&page_size=100',
expect.any(Object),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'http://127.0.0.1:8000/characters?project_id=3&page=2&page_size=100',
expect.any(Object),
)
})
})

function character(id: number) {
return {
id,
project_id: 3,
description: null,
reference_image_url: null,
status: 1,
character_data: { version: 1, outfits: [] },
}
}

function listResponse(data: unknown[], total: number, page: number, pageSize: number) {
return new Response(
JSON.stringify({ code: 200, message: 'success', data, total, page, page_size: pageSize }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}

function jsonResponse(data: unknown) {
return new Response(JSON.stringify({ code: 200, message: 'success', data }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
180 changes: 180 additions & 0 deletions frontend/src/entities/character/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { Action, ActionType, Character, CharacterApis, Frame, Outfit } from '.'

import { del, get, getPage, patch } from '@/shared/api'

/* ─── 后端 DTO ─── */

interface BackendFrame {
index: number
image_url: string
duration_ms: number | null
root_motion?: { dx: number; dy: number } | null
}

interface BackendAction {
id: string
type: string
name: string
loop: boolean
fps: number
frame_count: number
frames: BackendFrame[]
}

interface BackendOutfit {
id: string
name: string
description: string | null
preview_url: string | null
actions: BackendAction[]
}

interface BackendCharacterData {
version: number
outfits: BackendOutfit[]
}

interface BackendCharacter {
id: number
project_id: number
description: string | null
reference_image_url: string | null
character_data: BackendCharacterData
status: number
}

/* ─── 映射 ─── */

const ACTION_TYPE_SET = new Set<string>(['walk', 'idle', 'attack', 'jump', 'custom'])

function toActionType(raw: string): ActionType {
return ACTION_TYPE_SET.has(raw) ? (raw as ActionType) : 'custom'
}

function toFrame(raw: BackendFrame): Frame {
return {
imageUrl: raw.image_url,
durationMs: raw.duration_ms,
rootMotion: raw.root_motion ?? null,
}
}

function toAction(raw: BackendAction, outfitId: string): Action {
return {
id: raw.id,
outfitId,
name: raw.name,
loop: raw.loop,
kind: 'custom', // 后端不区分 preset/custom
type: toActionType(raw.type),
fps: raw.fps,
keyFrameIndex: null, // 后端不提供关键帧索引
frames: raw.frames.sort((a, b) => a.index - b.index).map(toFrame),
}
}

function toOutfit(raw: BackendOutfit, characterId: string): Outfit {
return {
id: raw.id,
characterId,
name: raw.name,
candidateCharacterTemplates: [], // 后端 character_data 不含候选
characterTemplateUrl: raw.preview_url,
baseFrames: [],
actions: raw.actions.map((a) => toAction(a, raw.id)),
}
}

function toCharacter(raw: BackendCharacter): Character {
const id = String(raw.id)
return {
id,
projectId: String(raw.project_id),
createdAt: '', // 后端列表不返回时间戳
updatedAt: '',
outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)),
}
}

/* ─── 适配器 ─── */

export function createCharacterApis(): Pick<
CharacterApis,
'get' | 'listByProject' | 'update' | 'remove'
> {
return {
async get(id: string): Promise<Character> {
const raw = await get<BackendCharacter>(`/characters/${id}`)
return toCharacter(raw)
},

async listByProject(projectId: string): Promise<Character[]> {
const encodedProjectId = encodeURIComponent(projectId)
const pageSize = 100
const firstPage = await getPage<BackendCharacter>(
`/characters?project_id=${encodedProjectId}&page=1&page_size=${pageSize}`,
)

// page_size=0 是后端 ListResponse 的“已返回全量”标记,不需要继续翻页。
// 分页响应则按 total 继续读取,保证 Playtest 的角色切换器不会只显示前 100 个。
const all = [...firstPage.items]
if (firstPage.pageSize === 0) return all.map(toCharacter)

let currentPage = firstPage.page
while (all.length < firstPage.total) {
currentPage += 1
const nextPage = await getPage<BackendCharacter>(
`/characters?project_id=${encodedProjectId}&page=${currentPage}&page_size=${pageSize}`,
)
if (nextPage.page !== currentPage) {
throw new Error(`角色分页响应页码不一致:请求 ${currentPage},返回 ${nextPage.page}`)
}
if (nextPage.items.length === 0) {
throw new Error(`角色分页在读取完 total 前返回空页:${all.length}/${firstPage.total}`)
}
all.push(...nextPage.items)
if (nextPage.pageSize === 0) break
}
return all.map(toCharacter)
},

async update(character: Character): Promise<Character> {
const payload = {
project_id: Number(character.projectId),
character_data: {
version: 1,
outfits: character.outfits.map((outfit) => ({
id: outfit.id,
name: outfit.name,
description: null,
preview_url: outfit.characterTemplateUrl,
actions: outfit.actions.map((action) => ({
id: action.id,
type: action.type,
name: action.name,
loop: action.loop ?? false,
fps: action.fps,
frame_count: action.frames.length,
frames: action.frames.map((frame, index) => ({
index,
image_url: frame.imageUrl,
duration_ms: frame.durationMs,
root_motion: frame.rootMotion,
})),
})),
})),
},
}
const raw = await patch<BackendCharacter>(`/characters/${character.id}`, payload)
const saved = toCharacter(raw)
if (saved.projectId !== character.projectId) {
throw new Error('后端未保存新的项目归属')
}
return saved
},

async remove(id: string): Promise<void> {
await del(`/characters/${id}`)
},
}
}
Loading