Skip to content
Closed
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
8 changes: 8 additions & 0 deletions frontend/src/app/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,12 @@ describe('App', () => {
expect(screen.getByRole('heading', { name: '资产库' })).toBeTruthy()
expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull()
})

it('provides a dedicated Playtest entry', () => {
window.history.replaceState({}, '', '/playtest')

render(<App />)

expect(screen.getByRole('heading', { name: 'Playtest' })).toBeTruthy()
})
})
18 changes: 16 additions & 2 deletions frontend/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes } from 'react-router'
import {
createCharacterApis,
createGenerationApis,
createPlaytestInspectionApis,
createProjectApis,
createWorkflowRunStore,
} from '@/entities'
Expand All @@ -14,6 +15,7 @@ import { HistoryPage } from '@/pages/history'
import { NotFoundPage } from '@/pages/not-found'
import { PlaytestDemoPage } from '@/pages/playtest/demo-page'
import { PlaytestPage } from '@/pages/playtest'
import { PlaytestCatalogPage } from '@/pages/playtest/catalog'
import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectCreatePage } from '@/pages/project-create'
import { ProjectsPage } from '@/pages/projects'
Expand All @@ -24,7 +26,14 @@ import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick
import { createWorkflowEditorService } from '@/pages/workflow-editor/service'

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

Expand Down Expand Up @@ -72,7 +81,8 @@ export function App() {
return { id: project.id, spriteSize: project.spriteSize }
},
})
return { projectApis, characterApis, quickStart, workflowEditor, store }
const playtestCatalog = { projects: projectApis, characters: characterApis }
return { projectApis, characterApis, quickStart, workflowEditor, store, playtestCatalog }
}, [])

return (
Expand Down Expand Up @@ -113,6 +123,10 @@ export function App() {
element={<WorkflowEditorPage service={services.workflowEditor} />}
/>
<Route path="/playtest/demo" element={<PlaytestDemoPage />} />
<Route
path="/playtest"
element={<PlaytestCatalogPage apis={services.playtestCatalog} />}
/>
<Route path="/playtest/:characterId/:outfitId" element={<PlaytestFromBackend />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AppHeader } from './app-header'
afterEach(cleanup)

describe('AppHeader', () => {
it('保留三个产品入口,并将工作流路由归入创作', () => {
it('保留产品入口,并将工作流路由归入创作', () => {
render(
<MemoryRouter initialEntries={['/workflow-editor/run-1']}>
<AppHeader />
Expand All @@ -19,4 +19,35 @@ describe('AppHeader', () => {
expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects')
expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page')
})

it('在 Playtest 中随页面滚动,其余页面继续悬浮', () => {
const { container, unmount } = render(
<MemoryRouter initialEntries={['/playtest/25/outfit-25-default']}>
<AppHeader />
</MemoryRouter>,
)

expect(container.querySelector('header')?.className.split(' ')).toContain('relative')
expect(container.querySelector('header')?.className.split(' ')).not.toContain('fixed')
unmount()

const projects = render(
<MemoryRouter initialEntries={['/projects']}>
<AppHeader />
</MemoryRouter>,
)

expect(projects.container.querySelector('header')?.className.split(' ')).toContain('fixed')
})

it('将 Playtest 标记为独立核验工作区', () => {
render(
<MemoryRouter initialEntries={['/playtest/25/outfit-25-default']}>
<AppHeader />
</MemoryRouter>,
)

expect(screen.getByText('动作预览与质量核验')).toBeTruthy()
expect(screen.queryByText('项目与历史记录')).toBeNull()
})
})
20 changes: 17 additions & 3 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ const productNavigation: ProductNavigationItem[] = [
{
to: '/projects',
label: '项目',
isActive: (pathname) => pathname.startsWith('/projects') || pathname.startsWith('/playtest'),
isActive: (pathname) => pathname.startsWith('/projects'),
},
{
to: '/playtest',
label: '预览台',
isActive: (pathname) => pathname.startsWith('/playtest'),
},
{
to: '/quick-start',
Expand All @@ -27,7 +32,11 @@ const productNavigation: ProductNavigationItem[] = [
]

function getWorkspaceLabel(pathname: string): { title: string; detail: string } {
if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) {
if (pathname.startsWith('/playtest')) {
return { title: 'Playtest', detail: '动作预览与质量核验' }
}

if (pathname.startsWith('/projects')) {
return { title: '项目与历史记录', detail: '角色、动作与完成版本' }
}

Expand All @@ -42,9 +51,14 @@ function getWorkspaceLabel(pathname: string): { title: string; detail: string }
export function AppHeader() {
const { pathname } = useLocation()
const workspace = getWorkspaceLabel(pathname)
const isPlaytest = pathname.startsWith('/playtest')

return (
<header className="pointer-events-none fixed inset-x-0 top-3.5 z-50 flex items-start justify-between gap-2 px-3 text-[#1c231e] sm:gap-4 sm:px-[18px]">
<header
className={`pointer-events-none z-50 flex items-start justify-between gap-2 px-3 text-[#1c231e] sm:gap-4 sm:px-[18px] ${
isPlaytest ? 'relative pt-3.5' : 'fixed inset-x-0 top-3.5'
}`}
>
<div className="pointer-events-auto flex min-h-[3.625rem] min-w-0 items-center gap-3 rounded-xl border border-[#171817]/14 bg-[#dfe3df] px-2.5 py-[7px] sm:min-w-[min(26rem,42vw)] sm:px-3.5">
<Link
to="/"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/layout/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ afterEach(cleanup)
describe('AppShell', () => {
it.each([
['/', '首页'],
['/playtest', 'Playtest'],
['/playtest/demo', 'Playtest'],
['/workflow-editor/run-1', 'Workflow Editor'],
])('为%s 使用全宽页面容器', (pathname) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface AppShellProps {
/** 全站外壳,全局导航常驻。 */
export function AppShell({ children }: AppShellProps) {
const { pathname } = useLocation()
const isPlaytestWorkspace = pathname.startsWith('/playtest/')
const isPlaytestWorkspace = pathname.startsWith('/playtest')
const isWorkflowWorkspace = pathname.startsWith('/workflow-editor')
const isHomePage = pathname === '/'

Expand Down
19 changes: 15 additions & 4 deletions frontend/src/entities/character/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import type {
Outfit,
} from '.'

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

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

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

interface BackendAction {
Expand Down Expand Up @@ -62,7 +63,7 @@ function toFrame(raw: BackendFrame): Frame {
return {
imageUrl: raw.image_url,
durationMs: raw.duration_ms,
rootMotion: null, // 后端不提供根位移
rootMotion: raw.root_motion ?? null,
}
}

Expand Down Expand Up @@ -114,7 +115,7 @@ export function createCharacterApis(): CharacterApis {
async listByProject(projectId: string): Promise<Character[]> {
// http-client 已解包 ApiEnvelope,data 字段就是角色数组本身
const raw = await get<BackendCharacter[]>(
`/characters?project_id=${encodeURIComponent(projectId)}`,
`/characters?project_id=${encodeURIComponent(projectId)}&page_size=100`,
)
return raw.map(toCharacter)
},
Expand All @@ -130,6 +131,7 @@ export function createCharacterApis(): CharacterApis {

async update(character: Character): Promise<Character> {
const payload = {
project_id: Number(character.projectId),
character_data: {
version: 1,
outfits: character.outfits.map((outfit) => ({
Expand All @@ -148,13 +150,22 @@ export function createCharacterApis(): CharacterApis {
index,
image_url: frame.imageUrl,
duration_ms: frame.durationMs,
root_motion: frame.rootMotion,
})),
})),
})),
},
}
const raw = await patch<BackendCharacter>(`/characters/${character.id}`, payload)
return toCharacter(raw)
const saved = toCharacter(raw)
if (saved.projectId !== character.projectId) {
throw new Error('后端未保存新的项目归属')
}
return saved
},

async remove(id: string): Promise<void> {
await del(`/characters/${id}`)
},
}
}
1 change: 1 addition & 0 deletions frontend/src/entities/character/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,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>
}
10 changes: 10 additions & 0 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export type {
export { createMediaApis } from './media/api'
export type { MediaApis, MediaCategory, MediaReference } from './media'

/* Playtest 核验 —— 每个动作当前最新的核验结论,不形成历史版本 */
export { createPlaytestInspectionApis } from './playtest-inspection/api'
export type {
PlaytestInspection,
PlaytestInspectionApis,
PlaytestInspectionStatus,
PlaytestInspectionTarget,
SavePlaytestInspectionInput,
} from './playtest-inspection'

/* 工作流 —— 节点与运行状态都由前端管理 */
export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run'
export type {
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/entities/playtest-inspection/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ApiError, get, post } from '@/shared/api'

import type {
PlaytestInspection,
PlaytestInspectionApis,
PlaytestInspectionTarget,
SavePlaytestInspectionInput,
} from '.'

interface BackendPlaytestInspection {
id: number
character_id: number
outfit_id: string
action_id: string
status: PlaytestInspection['status']
create_at: string
update_at: string
}

function toInspection(raw: BackendPlaytestInspection): PlaytestInspection {
return {
id: String(raw.id),
characterId: String(raw.character_id),
outfitId: raw.outfit_id,
actionId: raw.action_id,
status: raw.status,
createdAt: raw.create_at,
updatedAt: raw.update_at,
}
}

function queryFor(target: PlaytestInspectionTarget): string {
const query = new URLSearchParams({
character_id: target.characterId,
outfit_id: target.outfitId,
action_id: target.actionId,
})
return query.toString()
}

export function createPlaytestInspectionApis(): PlaytestInspectionApis {
return {
async get(target) {
try {
const raw = await get<BackendPlaytestInspection>(
`/playtest-inspections?${queryFor(target)}`,
)
return toInspection(raw)
} catch (cause) {
if (cause instanceof ApiError && cause.code === 404) return null
throw cause
}
},

async save(input: SavePlaytestInspectionInput) {
const raw = await post<BackendPlaytestInspection>('/playtest-inspections', {
character_id: Number(input.characterId),
outfit_id: input.outfitId,
action_id: input.actionId,
status: input.status,
})
return toInspection(raw)
},
}
}
24 changes: 24 additions & 0 deletions frontend/src/entities/playtest-inspection/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Playtest 对某个动作保存的当前核验结论,不属于资产或创作历史。 */
export type PlaytestInspectionStatus = 'passed' | 'issues_found'

export interface PlaytestInspectionTarget {
characterId: string
outfitId: string
actionId: string
}

export interface PlaytestInspection extends PlaytestInspectionTarget {
id: string
status: PlaytestInspectionStatus
createdAt: string
updatedAt: string
}

export interface SavePlaytestInspectionInput extends PlaytestInspectionTarget {
status: PlaytestInspectionStatus
}

export interface PlaytestInspectionApis {
get(target: PlaytestInspectionTarget): Promise<PlaytestInspection | null>
save(input: SavePlaytestInspectionInput): Promise<PlaytestInspection>
}
12 changes: 10 additions & 2 deletions frontend/src/features/publish/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const ACTION_NAMES: Record<ActionType, string> = {
custom: '自定义动作',
}

/** 动作 ID 绑定本次运行,允许同一角色保存多个 custom 或同类型动作。 */
export function buildPublishedActionId(characterId: string, runId: string): string {
return `${characterId}-${runId}`
}

/** 审核通过时才把 WorkflowRun 中的完整动画写入正式 Character 资产树。 */
export async function publishWorkflowRun(
characterApis: CharacterApis,
Expand All @@ -34,11 +39,14 @@ export async function publishWorkflowRun(
const character = await characterApis.get(run.characterId)
const outfit = character.outfits.find((item) => item.id === run.outfitId)
if (!outfit) throw new Error('角色中没有找到工作流关联的造型')
const actionId = `${character.id}-${result.actionType}`
const actionId = buildPublishedActionId(character.id, run.id)
const action = {
id: actionId,
outfitId: outfit.id,
name: ACTION_NAMES[result.actionType],
name:
result.actionType === 'custom'
? run.prompt?.trim() || '自定义动作'
: ACTION_NAMES[result.actionType],
kind: 'custom' as const,
type: result.actionType,
fps: 8,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/workflow-controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
type CreateWorkflowRunStateInput,
} from './workflow-state'

/** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */
/** 创建角色与给已有角色增加动作共用同一条运行状态机。 */
export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput

export interface WorkflowController {
Expand Down
Loading