Skip to content
Draft
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
20 changes: 20 additions & 0 deletions frontend/src/app/app.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @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 Playtest catalog', () => {
window.history.replaceState({}, '', '/playtest')

render(<App />)

expect(screen.getByRole('heading', { name: 'Playtest' })).toBeTruthy()
})
})
24 changes: 23 additions & 1 deletion frontend/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
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 { PlaytestCatalogPage } from '@/pages/playtest/catalog'
import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
import { WorkflowEditorPage } from '@/pages/workflow-editor'
import { AppShell } from './layout'

function PlaytestFromBackend() {
const apis = useMemo(
() => ({
characters: createCharacterApis(),
projects: createProjectApis(),
inspections: createPlaytestInspectionApis(),
}),
[],
)

return <PlaytestPage apis={apis} />
}

/**
* 路由表与全局外壳。
* 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
*/
export function App() {
const catalogApis = useMemo(
() => ({ projects: createProjectApis(), characters: createCharacterApis() }),
[],
)

return (
<BrowserRouter>
<AppShell>
Expand All @@ -27,7 +48,8 @@ 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="/playtest" element={<PlaytestCatalogPage apis={catalogApis} />} />
<Route path="/playtest/:characterId/:outfitId" element={<PlaytestFromBackend />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppShell>
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/app/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router'
import { Link, useLocation } from 'react-router'

/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */

Expand All @@ -10,6 +10,9 @@ export interface AppShellProps {

/** 全站外壳,全局导航常驻。 */
export function AppShell({ children }: AppShellProps) {
const { pathname } = useLocation()
const isPlaytest = pathname.startsWith('/playtest')

return (
<div className="min-h-screen bg-white text-slate-900">
<nav className="flex items-center justify-between border-b border-slate-200 px-6 py-3">
Expand All @@ -21,9 +24,12 @@ 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>
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
<main className={isPlaytest ? 'w-full' : 'mx-auto max-w-5xl px-6 py-8'}>{children}</main>
</div>
)
}
66 changes: 66 additions & 0 deletions frontend/src/entities/character/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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)
})
})

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

import { del, get, 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[]> {
// http-client 已解包 ApiEnvelope,data 字段就是角色数组本身
const raw = await get<BackendCharacter[]>(
`/characters?project_id=${encodeURIComponent(projectId)}&page_size=100`,
)
return raw.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}`)
},
}
}
3 changes: 3 additions & 0 deletions frontend/src/entities/character/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface Action {
id: string
outfitId: Outfit['id']
name: string
/** 是否在播放到末帧后从首帧继续;整树更新时必须原样保存。 */
loop?: boolean
/** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */
kind: ActionKind
/** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */
Expand Down Expand Up @@ -134,4 +136,5 @@ export interface CharacterApis {
listByProject(projectId: string): Promise<Character[]>
create(input: CreateCharacterInput): Promise<Character>
update(character: Character): Promise<Character>
remove(id: Character['id']): Promise<void>
}
12 changes: 12 additions & 0 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */
export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project'
export { createProjectApis } from './project/api'
export type {
CharacterPerspective,
CreateProjectInput,
Expand All @@ -29,6 +30,7 @@ export type {
FrameRootMotion,
Outfit,
} from './character'
export { createCharacterApis } from './character/api'

/* 动作模板 —— 能跨角色复用的配方 */
export type { ActionTemplate, ActionTemplateApis } from './action-template'
Expand All @@ -55,6 +57,16 @@ export type {
/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */
export type { MediaReference } from './media'

/* Playtest 核验 —— 只保存某个动作当前的核验结论,不承担历史记录。 */
export { createPlaytestInspectionApis } from './playtest-inspection/api'
export type {
PlaytestInspection,
PlaytestInspectionApis,
PlaytestInspectionStatus,
PlaytestInspectionTarget,
SavePlaytestInspectionInput,
} from './playtest-inspection'

/* 工作流 —— 节点与运行状态都由前端管理 */
export { WORKFLOW_STEP_ORDER } from './workflow-run'
export type {
Expand Down
Loading