From d94788d24bc7c34cc926234b622249b6e938e6aa Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:18 +0800 Subject: [PATCH 01/15] feat(projects): implement project API adapter Projects need a domain adapter for the backend contract in PR 75. Map project DTOs, enums, pagination queries, creation, lookup, and deletion. Project pages can use typed data without handling transport fields. --- frontend/src/entities/project/index.ts | 178 ++++++++++++++++++++----- 1 file changed, 143 insertions(+), 35 deletions(-) diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 4c71164..7915235 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -1,32 +1,25 @@ +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' import type { Paged, PageQuery } from '@/shared/pagination' -/** Project 前端领域形状;字段只表达当前页面需要,不对应任何已确认后端 DTO。 */ +/** Project 前端领域形状;字段名由本模块显式映射后端 ProjectOut。 */ export interface Project { id: string - /** Project 所属用户 ID;认证来源尚未冻结。 */ ownerId: string + workflowId: string | null name: string - /** 游戏视角,见 CHARACTER_PERSPECTIVE。 */ perspective: CharacterPerspective - /** 移动方向,见 DIRECTIONAL_MOVEMENT。 */ directionalMovement: DirectionalMovement - /** 当前页面使用建议档位,后端范围尚未确认。 */ spriteSize: { width: number; height: number } - /** 项目级画风描述,作为本项目所有角色和动作生成的视觉约束。 */ gameStyle: string | null - /** - * 项目级画风参考图,本项目所有角色和动作都遵循它的视觉风格。 - * 它不决定某个角色具体长什么样;角色自身参考图由 CreateCharacterInput.referenceImageUrl 表达。 - */ sampleImageUrl: string | null - /** ISO 8601 字符串。 */ createdAt: string - /** ISO 8601 字符串。 */ updatedAt: string } -/** 新建项目的入参。 */ +/** 后端创建 Project 需要的完整字段;创建流程本身不属于资产库页面。 */ export interface CreateProjectInput { + ownerId: string + workflowId?: string | null name: string perspective: CharacterPerspective directionalMovement: DirectionalMovement @@ -35,48 +28,163 @@ export interface CreateProjectInput { sampleImageUrl?: string | null } -/** 更新项目设置的入参;未提供的字段保持不变。 */ -export interface UpdateProjectInput { - name?: string - perspective?: CharacterPerspective - directionalMovement?: DirectionalMovement - spriteSize?: { width: number; height: number } - gameStyle?: string | null - sampleImageUrl?: string | null +export interface ProjectPageQuery extends PageQuery { + /** 对应后端 user_id;后端按登录用户强制隔离前保持可选。 */ + ownerId?: string } -/** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ +/** 后端 character_perspective: 1 横版 / 2 俯视 / 3 2.5D。 */ export type CharacterPerspective = 'side' | 'top-down' | 'isometric' -/** 前端使用的移动方向枚举;后端映射尚未冻结。 */ +/** 后端 directional_movement: 1 单向 / 2 四向 / 3 八向。 */ export type DirectionalMovement = 'single' | 'four-way' | 'eight-way' -/** 游戏视角的页面文案。 */ export const CHARACTER_PERSPECTIVE: Record = { side: '横版视角', 'top-down': '俯视', isometric: '2.5D', } -/** - * 移动方向,决定一个动作要生成几套朝向的帧。 - * 多朝向在 Action 上如何存放尚未定义,当前 Action.frames 只表达单朝向; - * 选了四向/八向的项目,生成侧还接不上,见 Action.frames 的说明。 - */ export const DIRECTIONAL_MOVEMENT: Record = { single: '单向', 'four-way': '四向', 'eight-way': '八向', } -/** UI 使用的建议尺寸档位;不代表后端约束。 */ -export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const - -/** Project 对应的一组后端接口。 */ +/** Project 对应的一组后端接口。PR #75 未提供更新端点,因此这里不声明 update。 */ export interface ProjectApis { - list(query?: PageQuery): Promise> + list(query?: ProjectPageQuery): Promise> get(id: Project['id']): Promise create(input: CreateProjectInput): Promise - update(id: Project['id'], input: UpdateProjectInput): Promise remove(id: Project['id']): Promise } + +interface ProjectDto { + id: number + user_id: number + workflow_id: number | null + project_name: string + character_perspective: number + directional_movement: number + sprite_width: number + sprite_height: number + game_style: string | null + sprite_sample_url: string | null + create_at: string + update_at: string +} + +const perspectiveFromDto: Record = { + 1: 'side', + 2: 'top-down', + 3: 'isometric', +} + +const perspectiveToDto: Record = { + side: 1, + 'top-down': 2, + isometric: 3, +} + +const movementFromDto: Record = { + 1: 'single', + 2: 'four-way', + 3: 'eight-way', +} + +const movementToDto: Record = { + single: 1, + 'four-way': 2, + 'eight-way': 3, +} + +function mapEnumValue(value: number, values: Record, field: string): T { + const mapped = values[value] + if (mapped !== undefined) return mapped + throw new ApiError(`后端 Project.${field} 无效`, { + kind: 'invalid-response', + data: value, + }) +} + +function toBackendId(value: string, field: string): number { + const parsed = Number(value) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + throw new TypeError(`${field} 必须是正整数 ID`) +} + +function mapProject(dto: ProjectDto): Project { + return { + id: String(dto.id), + ownerId: String(dto.user_id), + workflowId: dto.workflow_id === null ? null : String(dto.workflow_id), + name: dto.project_name, + perspective: mapEnumValue( + dto.character_perspective, + perspectiveFromDto, + 'character_perspective', + ), + directionalMovement: mapEnumValue( + dto.directional_movement, + movementFromDto, + 'directional_movement', + ), + spriteSize: { width: dto.sprite_width, height: dto.sprite_height }, + gameStyle: dto.game_style, + sampleImageUrl: dto.sprite_sample_url, + createdAt: dto.create_at, + updatedAt: dto.update_at, + } +} + +function getApiClient() { + return createApiClient({ getAccessToken: getApiAccessToken }) +} + +export const projectApis: ProjectApis = { + async list(query = {}) { + const result = await getApiClient().requestList('/projects', { + query: { + page: query.page, + page_size: query.pageSize, + user_id: query.ownerId ? toBackendId(query.ownerId, 'ownerId') : undefined, + }, + }) + return { ...result, items: result.items.map(mapProject) } + }, + + async get(id) { + return mapProject( + await getApiClient().request(`/projects/${encodeURIComponent(id)}`), + ) + }, + + async create(input) { + const dto = await getApiClient().request('/projects', { + method: 'POST', + json: { + user_id: toBackendId(input.ownerId, 'ownerId'), + workflow_id: + input.workflowId === undefined + ? undefined + : input.workflowId === null + ? null + : toBackendId(input.workflowId, 'workflowId'), + project_name: input.name, + character_perspective: perspectiveToDto[input.perspective], + directional_movement: movementToDto[input.directionalMovement], + sprite_width: input.spriteSize.width, + sprite_height: input.spriteSize.height, + game_style: input.gameStyle, + sprite_sample_url: input.sampleImageUrl, + }, + }) + return mapProject(dto) + }, + + async remove(id) { + await getApiClient().request(`/projects/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) + }, +} From 83c55755f9f643db20191ca2cbc44445ec0f2b18 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:25 +0800 Subject: [PATCH 02/15] test(projects): cover project API adapter Project transport mapping must stay aligned with the backend contract. Cover pagination, DTO mapping, writes, deletion, and bearer token injection. Contract regressions fail before reaching the project pages. --- frontend/src/entities/project/index.test.ts | 147 ++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 frontend/src/entities/project/index.test.ts diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts new file mode 100644 index 0000000..d630e14 --- /dev/null +++ b/frontend/src/entities/project/index.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const projectDto = { + id: 42, + user_id: 7, + workflow_id: 9, + project_name: '点灯人', + character_perspective: 3, + directional_movement: 2, + sprite_width: 64, + sprite_height: 96, + game_style: null, + sprite_sample_url: 'https://cdn.windup.test/style.png', + create_at: '2026-08-01T08:00:00Z', + update_at: '2026-08-02T09:30:00Z', +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.resetModules() +}) + +async function loadProjectApis(fetchFn: typeof fetch) { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', fetchFn) + return (await import('./index')).projectApis +} + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + headers: { 'content-type': 'application/json' }, + }) +} + +describe('projectApis', () => { + it('maps the paged Project response and pagination query', async () => { + let request: Request | undefined + const projectApis = await loadProjectApis(async (input, init) => { + request = new Request(input, init) + return new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [projectDto], + total: 21, + page: 2, + page_size: 10, + }), + { headers: { 'content-type': 'application/json' } }, + ) + }) + + await expect(projectApis.list({ page: 2, pageSize: 10, ownerId: '7' })).resolves.toEqual({ + items: [ + { + id: '42', + ownerId: '7', + workflowId: '9', + name: '点灯人', + perspective: 'isometric', + directionalMovement: 'four-way', + spriteSize: { width: 64, height: 96 }, + gameStyle: null, + sampleImageUrl: 'https://cdn.windup.test/style.png', + createdAt: '2026-08-01T08:00:00Z', + updatedAt: '2026-08-02T09:30:00Z', + }, + ], + total: 21, + page: 2, + pageSize: 10, + }) + expect(request?.url).toBe('https://api.windup.test/projects?page=2&page_size=10&user_id=7') + }) + + it('serializes CreateProjectInput to the backend request body', async () => { + let request: Request | undefined + const projectApis = await loadProjectApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(projectDto) + }) + + await projectApis.create({ + ownerId: '7', + workflowId: '9', + name: '点灯人', + perspective: 'isometric', + directionalMovement: 'four-way', + spriteSize: { width: 64, height: 96 }, + gameStyle: null, + sampleImageUrl: 'https://cdn.windup.test/style.png', + }) + + expect(request?.method).toBe('POST') + await expect(request?.json()).resolves.toEqual({ + user_id: 7, + workflow_id: 9, + project_name: '点灯人', + character_perspective: 3, + directional_movement: 2, + sprite_width: 64, + sprite_height: 96, + game_style: null, + sprite_sample_url: 'https://cdn.windup.test/style.png', + }) + }) + + it('requests one Project by its backend resource path', async () => { + let requestUrl = '' + const projectApis = await loadProjectApis(async (input) => { + requestUrl = String(input) + return jsonResponse(projectDto) + }) + + await projectApis.get('42') + + expect(requestUrl).toBe('https://api.windup.test/projects/42') + }) + + it('uses the access-token provider registered at the shared HTTP boundary', async () => { + let authorization: string | null = null + const projectApis = await loadProjectApis(async (input, init) => { + authorization = new Request(input, init).headers.get('authorization') + return jsonResponse(projectDto) + }) + const { registerApiAccessTokenProvider } = await import('@/shared/api') + const unregister = registerApiAccessTokenProvider(() => 'project-access-token') + + await projectApis.get('42') + unregister() + + expect(authorization).toBe('Bearer project-access-token') + }) + + it('deletes one Project through the backend resource path', async () => { + let request: Request | undefined + const projectApis = await loadProjectApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(null) + }) + + await expect(projectApis.remove('42')).resolves.toBeUndefined() + expect(request?.url).toBe('https://api.windup.test/projects/42') + expect(request?.method).toBe('DELETE') + }) +}) From 4087d050d55867e728cbc4a2456722b998162299 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:32 +0800 Subject: [PATCH 03/15] feat(projects): implement character asset API adapter The asset library needs the persisted character hierarchy from PR 75. Map character, outfit, action, and frame data with complete tree updates. Pages can browse formal assets without generated or mock-only fields. --- frontend/src/entities/character/index.ts | 305 ++++++++++++++++------- 1 file changed, 209 insertions(+), 96 deletions(-) diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 616b5db..43c2cf6 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -1,137 +1,250 @@ -/** - * 动作「如何被定义」的来源维度:preset 复用预设定义,custom 由用户自定义。 - * 它与动作做什么的 ActionType 相互独立,例如 custom + walk 和 preset + custom 都是合法组合。 - */ -export type ActionKind = 'preset' | 'custom' - -/** - * 动作「做什么」的业务语义维度;custom 表示不属于当前内置语义枚举。 - * 它不表示定义来源:custom 来源仍可描述 walk,preset 来源也可承载 custom 业务语义。 - */ -export type ActionType = 'walk' | 'idle' | 'attack' | 'jump' | 'custom' +import { createApiClient, getApiAccessToken } from '@/shared/api' +import type { Paged, PageQuery } from '@/shared/pagination' -/** 单帧相对动作首帧的根位移,单位为像素。 */ -export interface FrameRootMotion { - dx: number - /** 正值表示向上。 */ - dy: number -} +/** PR #75 将动作类型定义为字符串;已知类型之外的后端扩展也应原样保留。 */ +export type ActionType = string -/** - * 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 - * - * 不带任何审核字段:服务端只交付生成好的帧,不返回质检结论;用户侧的审核也只是查看, - * 没有打回。前端若要做自动质检,那是读取帧之后在本地算出来的临时结论,不属于资产数据。 - */ export interface Frame { + /** 使用后端显式返回的帧序号,不用数组下标替代。 */ + index: number imageUrl: string - /** - * 此帧的显示时长,单位毫秒。 - * null 表示该帧没有独立时长,读取方才使用所属 Action.fps 计算等时长回退值。 - */ + /** null 时才按所属 Action.fps 计算等时长。 */ durationMs: number | null - /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ - rootMotion: FrameRootMotion | null } -/** 一张母版候选;attemptId 用于区分候选所属的生成尝试。 */ -export interface CharacterTemplateCandidate { - id: string - imageUrl: string - attemptId: string -} - -/** 确认母版候选时同时命名造型与候选,避免两个字符串 ID 在调用处颠倒。 */ -export interface ConfirmCharacterTemplateInput { - outfitId: string - candidateId: string -} - -/** 造型的基础参考帧;方向与质检结构等待真实资产契约后再扩展。 */ -export interface BaseFrame { - readonly imageUrl: string -} - -/** 某个角色造型下的一段动画动作。 */ export interface Action { - /** - * 仅在所属 Outfit 内唯一。动作没有自己的表,整棵树存在 character 记录里, - * 因此不存在全局唯一的动作 ID:任何按 ID 定位动作的地方都必须同时带上造型。 - */ + /** Action 只在所属 Outfit 内唯一。 */ id: string outfitId: Outfit['id'] name: string - /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ - kind: ActionKind - /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ type: ActionType - /** - * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; - * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 - */ + loop: boolean fps: number - /** - * 攻击触点、跳跃顶点等关键时刻在 frames 中的零基下标;null 表示没有明确关键帧。 - * 非 null 值必须指向当前 frames 数组内的成员。 - */ - keyFrameIndex: number | null - /** - * 按播放顺序排列的帧;数组下标就是零基帧序号。 - * 当前只表达单朝向。Project.directionalMovement 的四向/八向要如何落到这里 - * (本层再分组,还是一个朝向一条 Action)尚未有产品定义,不要凭猜先定结构。 - */ + frameCount: number frames: Frame[] } -/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ export interface Outfit { - /** - * 仅在所属 Character 内唯一。造型没有自己的表,与动作一起存在 character 记录里, - * 因此不存在全局唯一的造型 ID:任何按 ID 定位造型的地方都必须同时带上角色。 - */ + /** Outfit 只在所属 Character 内唯一。 */ id: string - characterId: string + characterId: Character['id'] name: string - /** 母版生成阶段返回的候选;生成完成前可以为空数组。 */ - candidateCharacterTemplates: CharacterTemplateCandidate[] - /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */ - characterTemplateUrl: string | null - /** 供后续动作生成使用的只读基础帧入口。 */ - readonly baseFrames: readonly BaseFrame[] - /** 每个 Action.outfitId 必须等于本造型 ID。 */ + description: string | null + previewUrl: string | null actions: Action[] } -/** - * 项目下的角色资产;造型拥有各自的母版和动作帧。 - * - * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 - * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 - */ +/** 项目下的角色资产;造型、动作与帧来自同一份 character_data。 */ export interface Character { id: string projectId: string - /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ + name: string | null + description: string | null + referenceImageUrl: string | null + /** character_data.version,更新整棵资产树时必须原样带回。 */ + dataVersion: number + status: number outfits: Outfit[] - createdAt: string - updatedAt: string } -/** 创建角色并发起母版生成所需的入参。 */ +/** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */ export interface CreateCharacterInput { projectId: string - /** 交给模型生成母版。 */ - description: string + name?: string | null + description?: string | null referenceImageUrl?: string | null } /** * Character 对应的一组后端接口。 - * 造型、动作和帧是 Character 内的完整树,不通过独立粒度方法写入;每次确认后整棵更新。 + * Outfit、Action、Frame 没有独立端点,更新时随 Character 整棵提交。 */ export interface CharacterApis { get(id: Character['id']): Promise - listByProject(projectId: string): Promise + listByProject(projectId: string, query?: PageQuery): Promise> create(input: CreateCharacterInput): Promise update(character: Character): Promise + remove(id: Character['id']): Promise +} + +interface CharacterFrameDto { + index: number + image_url: string + duration_ms: number | null +} + +interface CharacterActionDto { + id: string + type: string + name: string + loop: boolean + fps: number + frame_count: number + frames: CharacterFrameDto[] +} + +interface CharacterOutfitDto { + id: string + name: string + description: string | null + preview_url: string | null + actions: CharacterActionDto[] +} + +interface CharacterDataDto { + version: number + outfits: CharacterOutfitDto[] +} + +interface CharacterDto { + id: number + project_id: number + name: string | null + description: string | null + reference_image_url: string | null + character_data: CharacterDataDto + status: number +} + +function toBackendId(value: string, field: string): number { + const parsed = Number(value) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + throw new TypeError(`${field} 必须是正整数 ID`) +} + +function mapFrame(dto: CharacterFrameDto): Frame { + return { + index: dto.index, + imageUrl: dto.image_url, + durationMs: dto.duration_ms, + } +} + +function mapAction(dto: CharacterActionDto, outfitId: string): Action { + return { + id: dto.id, + outfitId, + name: dto.name, + type: dto.type, + loop: dto.loop, + fps: dto.fps, + frameCount: dto.frame_count, + frames: dto.frames.map(mapFrame), + } +} + +function mapOutfit(dto: CharacterOutfitDto, characterId: string): Outfit { + return { + id: dto.id, + characterId, + name: dto.name, + description: dto.description, + previewUrl: dto.preview_url, + actions: dto.actions.map((action) => mapAction(action, dto.id)), + } +} + +function mapCharacter(dto: CharacterDto): Character { + const characterId = String(dto.id) + return { + id: characterId, + projectId: String(dto.project_id), + name: dto.name, + description: dto.description, + referenceImageUrl: dto.reference_image_url, + dataVersion: dto.character_data.version, + status: dto.status, + outfits: dto.character_data.outfits.map((outfit) => mapOutfit(outfit, characterId)), + } +} + +function toFrameDto(frame: Frame): CharacterFrameDto { + return { + index: frame.index, + image_url: frame.imageUrl, + duration_ms: frame.durationMs, + } +} + +function toActionDto(action: Action): CharacterActionDto { + return { + id: action.id, + type: action.type, + name: action.name, + loop: action.loop, + fps: action.fps, + frame_count: action.frameCount, + frames: action.frames.map(toFrameDto), + } +} + +function toOutfitDto(outfit: Outfit): CharacterOutfitDto { + return { + id: outfit.id, + name: outfit.name, + description: outfit.description, + preview_url: outfit.previewUrl, + actions: outfit.actions.map(toActionDto), + } +} + +function getApiClient() { + return createApiClient({ getAccessToken: getApiAccessToken }) +} + +export const characterApis: CharacterApis = { + async get(id) { + return mapCharacter( + await getApiClient().request(`/characters/${encodeURIComponent(id)}`), + ) + }, + + async listByProject(projectId, query = {}) { + const result = await getApiClient().requestList('/characters', { + query: { + project_id: toBackendId(projectId, 'projectId'), + page: query.page, + page_size: query.pageSize, + }, + }) + return { ...result, items: result.items.map(mapCharacter) } + }, + + async create(input) { + const dto = await getApiClient().request('/characters', { + method: 'POST', + json: { + project_id: toBackendId(input.projectId, 'projectId'), + name: input.name, + description: input.description, + reference_image_url: input.referenceImageUrl, + }, + }) + return mapCharacter(dto) + }, + + async update(character) { + const dto = await getApiClient().request( + `/characters/${encodeURIComponent(character.id)}`, + { + method: 'PATCH', + json: { + name: character.name, + description: character.description, + reference_image_url: character.referenceImageUrl, + character_data: { + version: character.dataVersion, + outfits: character.outfits.map(toOutfitDto), + }, + }, + }, + ) + return mapCharacter(dto) + }, + + async remove(id) { + await getApiClient().request(`/characters/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) + }, } From 6e26fad6c094b72a2af7629ced21983fb0f7760e Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:40 +0800 Subject: [PATCH 04/15] test(projects): cover character asset API adapter Character tree mapping carries the core project asset contract. Cover list, detail, create, update, delete, and token-aware requests. Nested asset serialization remains protected by executable tests. --- frontend/src/entities/character/index.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 frontend/src/entities/character/index.test.ts diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts new file mode 100644 index 0000000..af0a21d --- /dev/null +++ b/frontend/src/entities/character/index.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const characterDto = { + id: 51, + project_id: 42, + name: '轻装信使', + description: null, + reference_image_url: 'https://cdn.windup.test/reference.png', + character_data: { + version: 2, + outfits: [ + { + id: 'outfit-default', + name: '常态造型', + description: '旅行装束', + preview_url: 'https://cdn.windup.test/outfit.png', + actions: [ + { + id: 'walk', + type: 'walk', + name: '行走', + loop: true, + fps: 10, + frame_count: 2, + frames: [ + { + index: 1, + image_url: 'https://cdn.windup.test/walk-02.png', + duration_ms: 120, + }, + { + index: 0, + image_url: 'https://cdn.windup.test/walk-01.png', + duration_ms: null, + }, + ], + }, + ], + }, + ], + }, + status: 1, +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.resetModules() +}) + +async function loadCharacterApis(fetchFn: typeof fetch) { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', fetchFn) + return (await import('./index')).characterApis +} + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + headers: { 'content-type': 'application/json' }, + }) +} + +describe('characterApis', () => { + it('maps the paged Character tree and its 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: 1, + page_size: 20, + }), + { headers: { 'content-type': 'application/json' } }, + ) + }) + + const page = await characterApis.listByProject('42', { page: 1, pageSize: 20 }) + + expect(requestUrl).toBe('https://api.windup.test/characters?project_id=42&page=1&page_size=20') + expect(page).toEqual({ + items: [ + { + id: '51', + projectId: '42', + name: '轻装信使', + description: null, + referenceImageUrl: 'https://cdn.windup.test/reference.png', + dataVersion: 2, + status: 1, + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: '旅行装束', + previewUrl: 'https://cdn.windup.test/outfit.png', + actions: [ + { + id: 'walk', + outfitId: 'outfit-default', + type: 'walk', + name: '行走', + loop: true, + fps: 10, + frameCount: 2, + frames: [ + { + index: 1, + imageUrl: 'https://cdn.windup.test/walk-02.png', + durationMs: 120, + }, + { + index: 0, + imageUrl: 'https://cdn.windup.test/walk-01.png', + durationMs: null, + }, + ], + }, + ], + }, + ], + }, + ], + total: 1, + page: 1, + pageSize: 20, + }) + }) + + it('serializes CreateCharacterInput without inventing generated assets', async () => { + let request: Request | undefined + const characterApis = await loadCharacterApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(characterDto) + }) + + await characterApis.create({ + projectId: '42', + name: '轻装信使', + description: '项目角色', + referenceImageUrl: null, + }) + + expect(request?.method).toBe('POST') + await expect(request?.json()).resolves.toEqual({ + project_id: 42, + name: '轻装信使', + description: '项目角色', + reference_image_url: null, + }) + }) + + it('requests one Character by its backend resource path', async () => { + let requestUrl = '' + const characterApis = await loadCharacterApis(async (input) => { + requestUrl = String(input) + return jsonResponse(characterDto) + }) + + await characterApis.get('51') + + expect(requestUrl).toBe('https://api.windup.test/characters/51') + }) + + it('uses the access-token provider registered at the shared HTTP boundary', async () => { + let authorization: string | null = null + const characterApis = await loadCharacterApis(async (input, init) => { + authorization = new Request(input, init).headers.get('authorization') + return jsonResponse(characterDto) + }) + const { registerApiAccessTokenProvider } = await import('@/shared/api') + const unregister = registerApiAccessTokenProvider(() => 'character-access-token') + + await characterApis.get('51') + unregister() + + expect(authorization).toBe('Bearer character-access-token') + }) + + it('serializes a complete Character tree for PATCH', async () => { + let request: Request | undefined + const characterApis = await loadCharacterApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(characterDto) + }) + const character = await characterApis.get('51') + + await characterApis.update(character) + + expect(request?.method).toBe('PATCH') + expect(request?.url).toBe('https://api.windup.test/characters/51') + await expect(request?.json()).resolves.toEqual({ + name: '轻装信使', + description: null, + reference_image_url: 'https://cdn.windup.test/reference.png', + character_data: { + version: 2, + outfits: characterDto.character_data.outfits, + }, + }) + }) + + it('deletes one Character through the backend resource path', async () => { + let request: Request | undefined + const characterApis = await loadCharacterApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(null) + }) + + await expect(characterApis.remove('51')).resolves.toBeUndefined() + expect(request?.url).toBe('https://api.windup.test/characters/51') + expect(request?.method).toBe('DELETE') + }) +}) From 37ba9e818fbd7c262702da540c07d7cafaed4d11 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:49 +0800 Subject: [PATCH 05/15] feat(entities): expose project asset APIs Pages consume entities through the layer public entry point. Export the implemented project and character APIs with their domain types. Project pages keep respecting the existing dependency boundary. --- frontend/src/entities/index.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359d..79a3d21 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,34 +1,28 @@ -/** - * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 - */ +/** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ -export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' +export { projectApis } from './project' export type { CharacterPerspective, CreateProjectInput, DirectionalMovement, Project, ProjectApis, - UpdateProjectInput, + ProjectPageQuery, } from './project' /* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ export type { Action, - ActionKind, ActionType, - BaseFrame, Character, CharacterApis, - CharacterTemplateCandidate, - ConfirmCharacterTemplateInput, CreateCharacterInput, Frame, - FrameRootMotion, Outfit, } from './character' +export { characterApis } from './character' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' From 98d0f6fb9861a386f4e9311c7f986d3f845e1bf4 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:58 +0800 Subject: [PATCH 06/15] feat(ui): add pagination controls Backend project and character lists can span multiple pages. Add a business-neutral pagination control and export it from shared UI. List pages can navigate all records without duplicating controls. --- frontend/src/shared/ui/index.ts | 2 ++ frontend/src/shared/ui/pagination.tsx | 45 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 frontend/src/shared/ui/pagination.tsx diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index dab9f90..8aeaf70 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,2 +1,4 @@ export { PageContainer } from './page-container' export type { PageContainerProps } from './page-container' +export { Pagination } from './pagination' +export type { PaginationProps } from './pagination' diff --git a/frontend/src/shared/ui/pagination.tsx b/frontend/src/shared/ui/pagination.tsx new file mode 100644 index 0000000..59c7ba3 --- /dev/null +++ b/frontend/src/shared/ui/pagination.tsx @@ -0,0 +1,45 @@ +export interface PaginationProps { + page: number + pageSize: number + total: number + disabled?: boolean + onPageChange: (page: number) => void +} + +/** 后端分页列表共用的最小翻页控件,不解释具体业务项。 */ +export function Pagination({ + page, + pageSize, + total, + disabled = false, + onPageChange, +}: PaginationProps) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + if (totalPages === 1) return null + + return ( + + ) +} From ff5dd4b8b881fc7be250d5676f4d130e6f00a1f0 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:43:05 +0800 Subject: [PATCH 07/15] feat(projects): implement project navigation workspace Projects need an entry page and a persistent project-level workspace. Implement paged project browsing, deletion, navigation, and project constraints. Users can enter one project before managing its contained assets. --- frontend/src/pages/project-detail/index.tsx | 141 ++++++++++- frontend/src/pages/projects/index.tsx | 245 +++++++++++++++++++- 2 files changed, 373 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index 911b1e4..2ea2fde 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -1,13 +1,138 @@ -import { PageContainer } from '@/shared/ui' +import { useEffect, useState } from 'react' +import { Link, Outlet, useLocation, useParams } from 'react-router' -/** 项目详情。 */ +import { + CHARACTER_PERSPECTIVE, + DIRECTIONAL_MOVEMENT, + characterApis, + projectApis, + type Project, +} from '@/entities' + +/** 项目常驻工作区;子路由负责具体资产内容。 */ export function ProjectDetailPage() { + const { projectId } = useParams() + const location = useLocation() + const [project, setProject] = useState(null) + const [characterCount, setCharacterCount] = useState(0) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId) { + setError('缺少项目 ID') + return () => { + active = false + } + } + + setProject(null) + setError(null) + void Promise.all([ + projectApis.get(projectId), + characterApis.listByProject(projectId, { page: 1, pageSize: 1 }), + ]).then( + ([nextProject, charactersPage]) => { + if (!active) return + setProject(nextProject) + setCharacterCount(charactersPage.total) + }, + () => { + if (active) setError('这个项目不存在或暂时无法读取') + }, + ) + + return () => { + active = false + } + }, [projectId]) + + if (error) { + return ( +

+ {error} +

+ ) + } + if (!project) return

正在读取项目…

+ + const constraints = [ + ['视角', CHARACTER_PERSPECTIVE[project.perspective]], + ['朝向', DIRECTIONAL_MOVEMENT[project.directionalMovement]], + ['尺寸', `${project.spriteSize.width} × ${project.spriteSize.height}`], + ['画风', project.gameStyle ?? '尚未设定'], + ] + return ( - -
-

项目详情

-

本次只提交模块划分与接口,页面实现进后续 PR。

-
-
+
+ + +
+
+ +
+
+
) } diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index 6c252ea..aebfc6c 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,13 +1,248 @@ -import { PageContainer } from '@/shared/ui' +import { useEffect, useState, type CSSProperties } from 'react' +import { Link } from 'react-router' -/** 项目列表。 */ +import { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis, type Project } from '@/entities' +import type { Paged } from '@/shared/pagination' +import { PageContainer, Pagination } from '@/shared/ui' + +const PROJECT_PAGE_SIZE = 12 + +/** 项目中心;项目是角色资产与生成规格的隔离边界。 */ export function ProjectsPage() { + const [pageNumber, setPageNumber] = useState(1) + const [projectsPage, setProjectsPage] = useState | null>(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + setProjectsPage(null) + setError(null) + void projectApis.list({ page: pageNumber, pageSize: PROJECT_PAGE_SIZE }).then( + (page) => { + if (active) setProjectsPage(page) + }, + () => { + if (active) setError('项目暂时无法读取') + }, + ) + return () => { + active = false + } + }, [pageNumber]) + + async function deleteProject(project: Project) { + setDeleting(true) + setError(null) + try { + await projectApis.remove(project.id) + if (projectsPage?.items.length === 1 && projectsPage.page > 1) { + setPageNumber(projectsPage.page - 1) + } else { + setProjectsPage((current) => + current + ? { + ...current, + items: current.items.filter((item) => item.id !== project.id), + total: Math.max(0, current.total - 1), + } + : current, + ) + } + setDeleteTarget(null) + } catch { + setError('项目暂时无法删除') + } finally { + setDeleting(false) + } + } + return ( -
-

项目列表

-

本次只提交模块划分与接口,页面实现进后续 PR。

+
+
+
+

+ 项目中心 +

+

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

+
+ +
+ + {error ? ( +

+ {error} +

+ ) : projectsPage === null ? ( +

正在读取项目…

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

还没有项目

+

完成新建项目流程后,项目会显示在这里。

+
+ ) : ( +
+ {projectsPage.items.map((project, index) => ( + setDeleteTarget(project)} + /> + ))} +
+ )} + {projectsPage ? ( + + ) : null}
+ + {deleteTarget ? ( + setDeleteTarget(null)} + onConfirm={() => deleteProject(deleteTarget)} + /> + ) : null} ) } + +function ProjectCard({ + project, + motionOrder, + onDelete, +}: { + project: Project + motionOrder: number + onDelete: () => void +}) { + const updatedAt = new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + }).format(new Date(project.updatedAt)) + + return ( +
+ +
+

+ {project.name} +

+

更新于 {updatedAt}

+
+
+
视角 / 朝向
+
+ {CHARACTER_PERSPECTIVE[project.perspective]} ·{' '} + {DIRECTIONAL_MOVEMENT[project.directionalMovement]} +
+
+
+
精灵尺寸
+
+ {project.spriteSize.width} × {project.spriteSize.height} +
+
+
+
画风约束
+
+ {project.gameStyle ?? '尚未设定'} +
+
+
+
+ + +
+ ) +} + +function DeleteProjectDialog({ + project, + pending, + onClose, + onConfirm, +}: { + project: Project + pending: boolean + onClose: () => void + onConfirm: () => Promise +}) { + return ( +
+
+

删除“{project.name}”?

+

+ 删除后无法恢复这条项目记录。请先确认项目下资产已经妥善处理。 +

+
+ + +
+
+
+ ) +} From 47d20ffcef50f58d1423ac7d3a90611369ff54ce Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:43:12 +0800 Subject: [PATCH 08/15] feat(projects): implement character asset browsing Project assets need formal browsing below the project workspace. Implement character cards, outfit selection, stacked actions, and frame expansion. The UI reflects only fields persisted by the character contract. --- frontend/src/pages/asset-library/index.tsx | 155 +++++++- frontend/src/pages/character-detail/index.tsx | 339 ++++++++++++++++++ 2 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/character-detail/index.tsx diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index b90da65..97cf2ef 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -1,13 +1,152 @@ -import { PageContainer } from '@/shared/ui' +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import { characterApis, type Character } from '@/entities' +import type { Paged } from '@/shared/pagination' +import { Pagination } from '@/shared/ui' + +const CHARACTER_PAGE_SIZE = 24 + +function characterName(character: Character) { + return character.name ?? '未命名角色' +} -/** 资产库。 */ export function AssetLibraryPage() { + const { projectId } = useParams() + const [pageNumber, setPageNumber] = useState(1) + const [charactersPage, setCharactersPage] = useState | null>(null) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId) { + setError('缺少项目 ID') + return () => { + active = false + } + } + + setCharactersPage(null) + setError(null) + void characterApis + .listByProject(projectId, { + page: pageNumber, + pageSize: CHARACTER_PAGE_SIZE, + }) + .then( + (page) => { + if (active) setCharactersPage(page) + }, + () => { + if (active) setError('资产库暂时无法读取') + }, + ) + return () => { + active = false + } + }, [pageNumber, projectId]) + + return ( +
+

+ 角色 +

+
+
+ +
+ {error ? ( +

+ {error} +

+ ) : charactersPage === null ? ( +

正在建立资产索引…

+ ) : ( + <> + + + + )} +
+
+ ) +} + +function CharacterGrid({ projectId, characters }: { projectId: string; characters: Character[] }) { + if (characters.length === 0) return + + return ( +
+ {characters.map((character) => { + const name = characterName(character) + const outfit = character.outfits[0] + const actionCount = character.outfits.reduce((sum, item) => sum + item.actions.length, 0) + return ( + +
+ {outfit?.previewUrl ? ( + {`${name}的${outfit.name}预览`} + ) : ( +
+ + 暂无造型预览 + +
+ )} +
+
+
+
+

{name}

+

{outfit?.name ?? '尚未创建造型'}

+
+ +
+
+ {character.outfits.length} 套造型 + · + {actionCount} 个动作 +
+
+ + ) + })} +
+ ) +} + +function EmptyState() { return ( - -
-

资产库

-

本次只提交模块划分与接口,页面实现进后续 PR。

-
-
+
+

这个项目还没有角色

+

角色会在创建工作流确认后进入这里。

+
) } diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx new file mode 100644 index 0000000..d3e28a8 --- /dev/null +++ b/frontend/src/pages/character-detail/index.tsx @@ -0,0 +1,339 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import { characterApis, type Action, type Character, type Outfit } from '@/entities' + +const ACTION_TYPE_LABELS: Record = { + walk: '行走', + idle: '待机', + attack: '攻击', + custom: '自定义', +} + +function actionTypeLabel(type: string) { + return ACTION_TYPE_LABELS[type] ?? type +} + +function orderedFrames(action: Action) { + return [...action.frames].sort((left, right) => left.index - right.index) +} + +function characterName(character: Character) { + return character.name ?? '未命名角色' +} + +export function CharacterDetailPage() { + const { projectId, characterId } = useParams() + const [character, setCharacter] = useState(null) + const [selectedOutfitId, setSelectedOutfitId] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId || !characterId) { + setError('缺少角色定位信息') + return () => { + active = false + } + } + + setCharacter(null) + setSelectedOutfitId(null) + setError(null) + void characterApis.get(characterId).then( + (nextCharacter) => { + if (!active) return + if (nextCharacter.projectId !== projectId) { + setError('这个角色不属于当前项目') + return + } + setCharacter(nextCharacter) + setSelectedOutfitId(nextCharacter.outfits[0]?.id ?? null) + }, + () => { + if (active) setError('这个角色不存在或暂时无法读取') + }, + ) + + return () => { + active = false + } + }, [characterId, projectId]) + + if (error) { + return ( +

+ {error} +

+ ) + } + if (!character) return

正在读取角色资产…

+ + const name = characterName(character) + const selectedOutfit = + character.outfits.find((outfit) => outfit.id === selectedOutfitId) ?? + character.outfits[0] ?? + null + + return ( +
+
+
+ + 返回资产库 + +

+ {name} +

+

选择动作卡片查看完整帧序列。

+
+
+ {selectedOutfit ? ( + + ) : null} +
+ +

+ 导出能力待 PR #97 合并并完成资产字段接线 +

+
+
+
+ + {character.outfits.length === 0 || !selectedOutfit ? ( +
+

这个角色还没有造型

+
+ ) : ( + <> +
+ +
+ + + )} +
+ ) +} + +function OutfitMaster({ character, outfit }: { character: Character; outfit: Outfit }) { + const name = characterName(character) + return ( +
+
+ {outfit.previewUrl ? ( + {`${name}的${outfit.name}预览`} + ) : ( +
+ + 暂无造型预览 + +
+ )} +
+
+

+ {outfit.name} +

+ {outfit.description ? ( +

{outfit.description}

+ ) : null} +

+ {outfit.actions.length} 个动作 +

+
+
+ ) +} + +function ActionList({ character, outfit }: { character: Character; outfit: Outfit }) { + const [selectedActionId, setSelectedActionId] = useState(null) + const selectedAction = outfit.actions.find((action) => action.id === selectedActionId) ?? null + + return ( +
+
+

+ 动作与帧 +

+
+ 点击卡片展开完整帧 + +
+
+ + {outfit.actions.length === 0 ? ( +
+

这个造型还没有动作

+

生成并保存动作后会显示在这里。

+
+ ) : ( + <> +
+ {outfit.actions.map((action, index) => { + const expanded = selectedAction?.id === action.id + const previewFrame = orderedFrames(action)[0] + return ( +
+ +
+ ) + })} +
+ + {selectedAction ? ( +
+
+
+
+

{selectedAction.name}

+ + {actionTypeLabel(selectedAction.type)} + +
+

+ {selectedAction.fps} FPS · {selectedAction.frameCount} 帧 ·{' '} + {selectedAction.loop ? '循环播放' : '单次播放'} +

+
+
+ + +
+
+

动作模板后端未提供

+

+ {characterName(character)} / {outfit.name} / {selectedAction.name} +

+
+
    + {orderedFrames(selectedAction).map((frame) => ( +
  1. +
    + {`${selectedAction.name}第 +
    +
    + #{String(frame.index + 1).padStart(2, '0')} + + {frame.durationMs === null + ? `按 ${selectedAction.fps} FPS` + : `${frame.durationMs} ms`} + +
    +
  2. + ))} +
+
+
+ ) : null} + + )} +
+ ) +} From 490f464bec0f45c382f1c6d63f49c8e4c6546112 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:43:24 +0800 Subject: [PATCH 09/15] feat(projects): mount project workspace routes Project pages require a nested route boundary outside the global shell. Mount the asset library and character detail beneath the project workspace. Direct routes preserve project context without duplicating global navigation. --- frontend/src/app/app.tsx | 45 ++++++++++++++++++++++++--------------- frontend/src/app/index.ts | 2 +- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 8c869ab..a7c1c32 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,6 +1,7 @@ -import { BrowserRouter, Route, Routes } from 'react-router' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { AssetLibraryPage } from '@/pages/asset-library' +import { CharacterDetailPage } from '@/pages/character-detail' import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' import { PlaytestPage } from '@/pages/playtest' @@ -13,26 +14,36 @@ import { AppShellRoute } from './layout' /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 - * 外壳的边界画在这张表上:全部路由都在里面,包括根路由——顶栏悬浮不占高度, - * 首屏仍是满幅,同时首页也才有通往项目资产的常驻入口。 + * 外壳的边界画在这张表上:首页与流程页使用全局顶栏;项目工作区使用自己的 + * 项目导航,不重复套全局外壳。 */ export function App() { return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + ) } + +/** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */ +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/app/index.ts b/frontend/src/app/index.ts index f1c81c7..c4279fd 100644 --- a/frontend/src/app/index.ts +++ b/frontend/src/app/index.ts @@ -1 +1 @@ -export { App } from './app' +export { App, AppRoutes } from './app' From 5c4b1696fa84b95ee8be6dab25fa7265982802e4 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:43:33 +0800 Subject: [PATCH 10/15] style(projects): add workspace transitions Project navigation currently changes state without visual continuity. Add restrained entry, route, dialog, and card transitions with reduced motion. Page changes remain legible without changing the established palette. --- frontend/src/index.css | 102 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index f80172a..42c544a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,3 +6,105 @@ body, #root { height: 100%; } + +@keyframes action-reveal { + from { + opacity: 0; + transform: translateY(-18px) scale(0.97); + clip-path: inset(0 8% 72% round 1.5rem); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + clip-path: inset(0 round 1.5rem); + } +} + +@keyframes projects-intro { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes projects-card-enter { + from { + opacity: 0; + transform: translateY(14px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes projects-dialog-backdrop { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes projects-dialog-panel { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes route-transition { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.projects-intro { + animation: projects-intro 420ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.projects-card-enter { + animation: projects-card-enter 500ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: calc(70ms + var(--project-card-order, 0) * 55ms); +} + +.projects-dialog-backdrop { + animation: projects-dialog-backdrop 180ms ease-out both; +} + +.projects-dialog-panel { + animation: projects-dialog-panel 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.route-transition { + animation: route-transition 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.action-reveal { + animation: action-reveal 520ms cubic-bezier(0.2, 0.9, 0.25, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .projects-intro, + .projects-card-enter, + .projects-dialog-backdrop, + .projects-dialog-panel, + .route-transition, + .action-reveal { + animation: none; + } +} From 350e639c8077cc91a36c694bed50965dfd03392c Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:43:41 +0800 Subject: [PATCH 11/15] test(projects): add HTTP backend fixture Page tests need realistic HTTP responses without production mock data. Provide a test-only Project and Character backend with configurable pagination. Production bundles remain independent from demonstration fixtures. --- frontend/src/test/project-assets-backend.ts | 227 ++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 frontend/src/test/project-assets-backend.ts diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts new file mode 100644 index 0000000..3931365 --- /dev/null +++ b/frontend/src/test/project-assets-backend.ts @@ -0,0 +1,227 @@ +const projectDtos = [ + { + id: 42, + user_id: 7, + workflow_id: null, + project_name: '点灯人 · MVP', + character_perspective: 1, + directional_movement: 2, + sprite_width: 64, + sprite_height: 64, + game_style: '低饱和像素绘本', + sprite_sample_url: null, + create_at: '2026-08-01T08:00:00Z', + update_at: '2026-08-04T10:30:00Z', + }, + { + id: 99, + user_id: 7, + workflow_id: null, + project_name: '空白海岸', + character_perspective: 2, + directional_movement: 3, + sprite_width: 128, + sprite_height: 128, + game_style: null, + sprite_sample_url: null, + create_at: '2026-08-02T08:00:00Z', + update_at: '2026-08-03T09:00:00Z', + }, +] + +const characterDtos = [ + { + id: 51, + project_id: 42, + name: '轻装信使', + description: '负责远途投递的年轻信使', + reference_image_url: 'https://cdn.windup.test/messenger-reference.png', + character_data: { + version: 1, + outfits: [ + { + id: 'outfit-default', + name: '常态造型', + description: '旅行装束', + preview_url: 'https://cdn.windup.test/messenger-outfit.png', + actions: [ + { + id: 'idle', + type: 'idle', + name: '呼吸待机', + loop: true, + fps: 8, + frame_count: 2, + frames: [ + { + index: 0, + image_url: 'https://cdn.windup.test/idle-01.png', + duration_ms: null, + }, + { + index: 1, + image_url: 'https://cdn.windup.test/idle-02.png', + duration_ms: 125, + }, + ], + }, + { + id: 'walk', + type: 'walk', + name: '行走', + loop: true, + fps: 10, + frame_count: 3, + frames: [ + { + index: 2, + image_url: 'https://cdn.windup.test/walk-03.png', + duration_ms: 100, + }, + { + index: 0, + image_url: 'https://cdn.windup.test/walk-01.png', + duration_ms: null, + }, + { + index: 1, + image_url: 'https://cdn.windup.test/walk-02.png', + duration_ms: 100, + }, + ], + }, + ], + }, + ], + }, + status: 1, + }, + { + id: 52, + project_id: 42, + name: '待定角色', + description: null, + reference_image_url: null, + character_data: { + version: 1, + outfits: [ + { + id: 'outfit-draft', + name: '未命名造型', + description: null, + preview_url: null, + actions: [], + }, + ], + }, + status: 1, + }, +] + +function response(data: unknown, message = 'success') { + return new Response(JSON.stringify({ code: 200, message, data }), { + headers: { 'content-type': 'application/json' }, + }) +} + +function listResponse(data: unknown[], page: number, pageSize: number, total: number) { + return new Response( + JSON.stringify({ + code: 200, + message: 'success', + data, + total, + page, + page_size: pageSize, + }), + { headers: { 'content-type': 'application/json' } }, + ) +} + +/** 测试环境中的 HTTP 服务替身;生产代码和生产包不会导入这里。 */ +export interface ProjectAssetsBackendOptions { + projectCount?: number + characterCount?: number +} + +function projectFixtures(count: number) { + const fixtures = structuredClone(projectDtos.slice(0, count)) + for (let index = fixtures.length; index < count; index += 1) { + fixtures.push({ + ...structuredClone(projectDtos[0]), + id: 1_000 + index, + project_name: `分页项目 ${index + 1}`, + }) + } + return fixtures +} + +function characterFixtures(count: number) { + const fixtures = structuredClone(characterDtos.slice(0, count)) + for (let index = fixtures.length; index < count; index += 1) { + fixtures.push({ + ...structuredClone(characterDtos[0]), + id: 2_000 + index, + name: `分页角色 ${index + 1}`, + }) + } + return fixtures +} + +export function createProjectAssetsBackend({ + projectCount = projectDtos.length, + characterCount = characterDtos.length, +}: ProjectAssetsBackendOptions = {}) { + let projects = projectFixtures(projectCount) + const characters = characterFixtures(characterCount) + const requests: Request[] = [] + + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init) + requests.push(request.clone()) + const url = new URL(request.url) + + if (request.method === 'GET' && url.pathname === '/projects') { + const page = Number(url.searchParams.get('page') ?? 1) + const pageSize = Number(url.searchParams.get('page_size') ?? 20) + const start = (page - 1) * pageSize + return listResponse(projects.slice(start, start + pageSize), page, pageSize, projects.length) + } + + if (url.pathname.startsWith('/projects/')) { + const projectId = Number(url.pathname.split('/').at(-1)) + const project = projects.find((item) => item.id === projectId) + if (request.method === 'GET' && project) return response(project) + if (request.method === 'DELETE' && project) { + projects = projects.filter((item) => item.id !== projectId) + return response(null, '删除成功') + } + return new Response(JSON.stringify({ code: 404, message: '项目不存在', data: null })) + } + + if (request.method === 'GET' && url.pathname === '/characters') { + const projectId = Number(url.searchParams.get('project_id')) + const page = Number(url.searchParams.get('page') ?? 1) + const pageSize = Number(url.searchParams.get('page_size') ?? 20) + const projectCharacters = characters.filter((item) => item.project_id === projectId) + const start = (page - 1) * pageSize + return listResponse( + projectCharacters.slice(start, start + pageSize), + page, + pageSize, + projectCharacters.length, + ) + } + + if (request.method === 'GET' && url.pathname.startsWith('/characters/')) { + const characterId = Number(url.pathname.split('/').at(-1)) + const character = characters.find((item) => item.id === characterId) + if (character) return response(character) + return new Response(JSON.stringify({ code: 404, message: '角色不存在', data: null })) + } + + throw new Error(`测试后端未处理 ${request.method} ${url.pathname}`) + } + + return { fetch, requests } +} From 24f7a6f17fa00794a1e365632604a42f936b4b04 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:43:51 +0800 Subject: [PATCH 12/15] test(projects): cover project navigation pages Project entry and workspace behavior need route-level regression coverage. Cover real HTTP data, deletion, pagination, constraints, and disabled boundaries. The project navigation flow remains verifiable without browser fixtures. --- .../src/pages/project-detail/index.test.tsx | 41 +++++++++ frontend/src/pages/projects/index.test.tsx | 87 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 frontend/src/pages/project-detail/index.test.tsx create mode 100644 frontend/src/pages/projects/index.test.tsx diff --git a/frontend/src/pages/project-detail/index.test.tsx b/frontend/src/pages/project-detail/index.test.tsx new file mode 100644 index 0000000..a453a0d --- /dev/null +++ b/frontend/src/pages/project-detail/index.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppRoutes } from '@/app' +import { createProjectAssetsBackend } from '@/test/project-assets-backend' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('ProjectDetailPage', () => { + it('keeps the Project workspace around a directly opened Character', async () => { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + + const { container } = render( + + + , + ) + + expect(await screen.findByRole('heading', { name: '点灯人 · MVP' })).toBeTruthy() + expect(screen.getByText('横版视角')).toBeTruthy() + expect(screen.getByText('四向')).toBeTruthy() + expect(screen.getByText('64 × 64')).toBeTruthy() + expect(screen.getByText('低饱和像素绘本')).toBeTruthy() + expect(screen.getByRole('link', { name: '返回项目中心' }).getAttribute('href')).toBe( + '/projects', + ) + expect(screen.getByRole('link', { name: /角色/ }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('button', { name: '动作模板' }).hasAttribute('disabled')).toBe(true) + expect(screen.queryByText('穿戴')).toBeNull() + expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() + expect(container.querySelector('[data-route-transition="/projects/42/assets/51"]')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx new file mode 100644 index 0000000..6136953 --- /dev/null +++ b/frontend/src/pages/projects/index.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppRoutes } from '@/app' +import { createProjectAssetsBackend } from '@/test/project-assets-backend' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +function installBackend() { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + return backend +} + +describe('ProjectsPage', () => { + it('renders backend Projects as the first browsing level', async () => { + installBackend() + const { container } = render( + + + , + ) + + expect(await screen.findByRole('heading', { name: '项目中心' })).toBeTruthy() + expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) + expect(screen.getByRole('link', { name: '打开项目 点灯人 · MVP' }).getAttribute('href')).toBe( + '/projects/42/assets', + ) + expect(screen.getByText('低饱和像素绘本')).toBeTruthy() + expect(container.querySelectorAll('[data-project-card]')).toHaveLength(2) + expect(screen.queryByRole('link', { name: /查看角色/ })).toBeNull() + }) + + it('keeps creation out of this module and deletes through the Project API', async () => { + const backend = installBackend() + render( + + + , + ) + + expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) + const createButton = screen.getByRole('button', { name: '新建项目' }) + expect(createButton.hasAttribute('disabled')).toBe(true) + expect(screen.queryByRole('dialog', { name: '新建项目' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '删除项目 空白海岸' })) + fireEvent.click(screen.getByRole('button', { name: '确认删除项目' })) + + await waitFor(() => { + expect(screen.queryByRole('link', { name: '打开项目 空白海岸' })).toBeNull() + }) + expect( + backend.requests.some( + (request) => request.method === 'DELETE' && request.url.endsWith('/projects/99'), + ), + ).toBe(true) + }) + + 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') + vi.stubGlobal('fetch', backend.fetch) + render( + + + , + ) + + expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(12) + fireEvent.click(screen.getByRole('button', { name: '下一页' })) + + await waitFor(() => { + expect(screen.getAllByRole('link', { name: /打开项目/ })).toHaveLength(1) + }) + expect( + backend.requests.some((request) => request.url.includes('/projects?page=2&page_size=12')), + ).toBe(true) + }) +}) From 9b609ab5cbdf7f940c6b29b0400ad5d5acd48d46 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:44:00 +0800 Subject: [PATCH 13/15] test(projects): cover character asset browsing Asset browsing must preserve project ownership and frame ordering. Cover pagination, empty states, outfits, stacked actions, and frame expansion. Unsupported template and export actions stay explicit and inert. --- .../src/pages/asset-library/index.test.tsx | 72 +++++++++++++++++++ .../src/pages/character-detail/index.test.tsx | 70 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 frontend/src/pages/asset-library/index.test.tsx create mode 100644 frontend/src/pages/character-detail/index.test.tsx diff --git a/frontend/src/pages/asset-library/index.test.tsx b/frontend/src/pages/asset-library/index.test.tsx new file mode 100644 index 0000000..182f1cc --- /dev/null +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppRoutes } from '@/app' +import { createProjectAssetsBackend } from '@/test/project-assets-backend' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +function renderRoute(route: string) { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + return render( + + + , + ) +} + +describe('AssetLibraryPage', () => { + it('renders only backend Character assets and their nested counts', async () => { + renderRoute('/projects/42/assets') + + expect(await screen.findByRole('heading', { name: '角色' })).toBeTruthy() + expect(await screen.findAllByRole('link', { name: /查看角色/ })).toHaveLength(2) + expect(screen.getByText('轻装信使')).toBeTruthy() + expect(screen.getByText('待定角色')).toBeTruthy() + expect(screen.getByText('暂无造型预览')).toBeTruthy() + expect(screen.getAllByText('1 套造型')).toHaveLength(2) + expect(screen.getByText('2 个动作')).toBeTruthy() + expect(screen.queryByRole('searchbox')).toBeNull() + expect(screen.queryByRole('button', { name: '导出全部角色资产' })).toBeNull() + }) + + it('renders the real empty state without creating a local Character', async () => { + renderRoute('/projects/99/assets') + + expect(await screen.findByText('这个项目还没有角色')).toBeTruthy() + const createButton = screen.getByRole('button', { name: '新建角色' }) + expect(createButton.hasAttribute('disabled')).toBe(true) + expect(screen.queryByRole('link', { name: /查看角色/ })).toBeNull() + }) + + it('navigates every backend Character page instead of truncating after the first page', async () => { + const backend = createProjectAssetsBackend({ characterCount: 25 }) + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + render( + + + , + ) + + expect(await screen.findAllByRole('link', { name: /查看角色/ })).toHaveLength(24) + fireEvent.click(screen.getByRole('button', { name: '下一页' })) + + await waitFor(() => { + expect(screen.getAllByRole('link', { name: /查看角色/ })).toHaveLength(1) + }) + expect( + backend.requests.some((request) => + request.url.includes('/characters?project_id=42&page=2&page_size=24'), + ), + ).toBe(true) + }) +}) diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx new file mode 100644 index 0000000..993724e --- /dev/null +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppRoutes } from '@/app' +import { createProjectAssetsBackend } from '@/test/project-assets-backend' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +function renderCharacter(characterId: string) { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', backend.fetch) + return render( + + + , + ) +} + +describe('CharacterDetailPage', () => { + it('uses the first ordered Frame as the Action preview', async () => { + renderCharacter('51') + + expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() + expect(screen.getByRole('combobox', { name: '选择造型' })).toBeTruthy() + expect(screen.getAllByRole('article', { name: /动作/ })).toHaveLength(2) + expect(screen.getByRole('img', { name: '呼吸待机帧预览' }).getAttribute('src')).toBe( + 'https://cdn.windup.test/idle-01.png', + ) + expect(screen.getByRole('img', { name: '行走帧预览' }).getAttribute('src')).toBe( + 'https://cdn.windup.test/walk-01.png', + ) + expect(screen.queryByText('GIF')).toBeNull() + expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: '导出资产包' }).hasAttribute('disabled')).toBe(true) + }) + + it('expands an Action into backend Frames sorted by index', async () => { + renderCharacter('51') + + expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开行走' })) + + const sequence = screen.getByRole('region', { name: '行走完整帧序列' }) + const frames = within(sequence).getAllByRole('img') + expect(frames.map((frame) => frame.getAttribute('src'))).toEqual([ + 'https://cdn.windup.test/walk-01.png', + 'https://cdn.windup.test/walk-02.png', + 'https://cdn.windup.test/walk-03.png', + ]) + expect( + within(sequence).getByRole('button', { name: '保存为动作模板' }).hasAttribute('disabled'), + ).toBe(true) + expect(screen.getByText('动作模板后端未提供')).toBeTruthy() + }) + + it('preserves the Outfit level when no Action exists', async () => { + renderCharacter('52') + + expect(await screen.findByRole('heading', { name: '待定角色' })).toBeTruthy() + expect(screen.getByRole('combobox', { name: '选择造型' })).toBeTruthy() + expect(screen.getByText('这个造型还没有动作')).toBeTruthy() + }) +}) From 1196b4e39360bcb679b167bfe7b39a12e7fa65c1 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:44:09 +0800 Subject: [PATCH 14/15] docs(projects): document project asset workspace The architecture documents still describe project pages as placeholders. Record the formal workspace, backend mappings, merge order, and excluded flows. Reviewers can distinguish current behavior from backend and future work. --- frontend-architecture-v3.md | 18 ++--- frontend/API_CONTRACT.md | 143 +++++++++++++----------------------- frontend/README.md | 2 +- 3 files changed, 60 insertions(+), 103 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 13e7a06..1dae537 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -41,7 +41,7 @@ pages -> features -> entities -> shared `app` 只做启动和路由,不构造服务、不向下注入。 -外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`。 +外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。首页、快速开始、Workflow Editor 与 Playtest 使用全局外壳;`/projects/:projectId/*` 是独立项目工作区,由 `ProjectDetailPage` 提供项目级导航,不重复套全局顶栏。外壳组件自身不读 pathname,不判断自己该不该出现;顶栏内部读 pathname 只为高亮当前项。外壳也不统一夹居中容器,宽度与留白由页面自己决定。 ### 依赖规则 @@ -86,18 +86,18 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 --- -## 5. 尚未包含 +## 5. 当前实现范围 -- 真实请求与数据获取,`XxxApis` 目前只有接口 -- 首页之外的页面实现,其余七个路由仍是占位外壳 -- 图片上传模块(体量太小,不单独体现) -- 穿戴道具相关(产品侧未设计) -- 第三方登录 +- `ProjectApis` 与 `CharacterApis` 实现 PR #75 的 Project、Character HTTP 契约;snake_case 只存在于各实体模块内部的 DTO 映射。 +- 项目中心、项目工作区、角色资产库、角色详情按 `Project → Character → Outfit → Action → Frame` 层级读取真实接口。 +- 测试通过 HTTP 替身返回契约数据;本模块的生产代码不包含 Mock API、演示实体或 livedemo 资产。 -首页已按本文的分层落地:它不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏那三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,写死在页面文案里,改流程时要一并改。 +本轮不包含新建项目流程、Workflow Editor 实现、Action Template 后端能力、导出接线、图片上传与登录流程。穿戴道具不作为独立资产层暴露。 + +首页仍不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,改流程时要一并改。 --- ## 6. 未与后端对齐的部分 -明细见 `frontend/API_CONTRACT.md`。 +PR #75 尚未合并,因此本实现要求先合并该后端 PR;契约明细与仍需后端处理的问题见 `frontend/API_CONTRACT.md`。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index ed90e36..5814fa6 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -1,119 +1,76 @@ # 前后端接口对齐清单 -前端各模块的 `XxxApis` 与后端 2026-07-30 接口文档逐条比对结果。 +本实现以尚未合并的后端 PR #75 为目标契约,并要求按 **#75 → 本前端 PR** 的顺序合并。`upstream/main` 当前尚未挂载这些接口。 -后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 +## 一、本轮已接入 ---- +### Project -## 一、已经确认的边界 - -- `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 -- `Character` 不使用独立 `name` 字段;前端已删除。 -- 前端保留 `jump` 动作类型,由后端补充对应枚举。 -- 查询生成任务统一携带 `projectId + taskId`。 -- 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: - -| 前端工作流节点 | 后端接口 | 后端任务类型 | +| 前端方法 | HTTP | 后端能力 | |---|---|---| -| `character_template` | `POST /generation/image` | `character_image` | -| `first_frame` | `POST /generation/image`,以上一步角色图作为参考图 | `character_image` | -| `complete_animation` | `POST /generation/action`,以已确认动作首帧作为参考图 | `character_action` | - -图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 - ---- - -## 二、前端预期有、后端目前没有 +| `ProjectApis.list` | `GET /projects` | `page`、`page_size`、可选 `user_id` | +| `ProjectApis.get` | `GET /projects/{id}` | 项目详情 | +| `ProjectApis.create` | `POST /projects` | 创建项目记录 | +| `ProjectApis.remove` | `DELETE /projects/{id}` | 删除项目记录 | -**这些接口仍需要确定由后端提供,还是改为前端本地能力。** +`ProjectOut` 的 `user_id`、`workflow_id`、`project_name`、`character_perspective`、`directional_movement`、精灵宽高、画风、参考图和时间字段,均在 `entities/project` 内显式映射为 camelCase。PR #75 没有项目更新端点,因此前端不声明 `ProjectApis.update`。 -| 前端接口 | 后端情况 | -|---|---| -| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| `ProjectApis.update` | 没有 `PATCH /projects/{project_id}` | +后端枚举按下表映射: -前端已按服务端现状去掉生成任务的 `cancel`——后端没有取消能力,不声明前端用不到的接口。 - ---- - -## 三、形状不一致 - -这些差异可以在前端接口层转换,不要求领域类型与后端 DTO 使用相同命名。 - -| 项 | 后端 | 前端 | +| 后端值 | `character_perspective` | `directional_movement` | |---|---|---| -| 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | -| 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `GenerationApis.subscribe`,实现时可封装轮询 | -| 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | -| 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | -| 角色视角 | `character_perspective` 为 `1~3`,文档中 2、3 都写成“正面” | `side` `top-down` `isometric` | - -ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不需要后端改动。 +| `1` | `side` | `single` | +| `2` | `top-down` | `four-way` | +| `3` | `isometric` | `eight-way` | ---- +### Character 资产树 -## 四、后端有、前端没接 - -| 后端 | 说明 | -|---|---| -| `delete_character` | 前端 `CharacterApis` 没有删除 | -| `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | -| `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | -| `MediaService.upload` | 前端本次未提交上传模块 | - ---- - -## 五、前端资产字段在后端没有落点 +| 前端方法 | HTTP | 后端能力 | +|---|---|---| +| `CharacterApis.listByProject` | `GET /characters?project_id=...` | 项目内角色分页列表 | +| `CharacterApis.get` | `GET /characters/{id}` | 角色详情 | +| `CharacterApis.create` | `POST /characters` | 创建空角色记录 | +| `CharacterApis.update` | `PATCH /characters/{id}` | 更新角色及完整资产树 | +| `CharacterApis.remove` | `DELETE /characters/{id}` | 删除角色及其媒体对象 | -后端 `character_data` 的嵌套结构(见 `character/model.py`): +后端持久化层级为: ```text -outfits[] → id / name / preview_url / actions[] -actions[] → id / type / name / loop / fps / frame_count / frames[] -frames[] → index / image_url / duration_ms +Character +└── character_data + └── outfits[] + └── actions[] + └── frames[] ``` -前端以下字段在后端结构里没有落点: - -- `Action.kind`(preset / custom 来源) -- `Action.keyFrameIndex` -- `Frame.rootMotion` -- `Outfit.candidateCharacterTemplates`(母版候选列表) -- `Outfit.characterTemplateUrl`(每套造型的已确认角色图) -- `Outfit.baseFrames` - -`candidateCharacterTemplates` 属于生成过程数据;若只在当前 WorkflowRun 中使用,可以留在前端。其余字段若要随最终资产恢复,需要后端增加字段,或者前端在 MVP 中删除。 - ---- - -## 六、概念不一致 - -后端 `character/model.py` 字段说明: +前端只映射后端真实字段: -> `reference_image_url`: 角色参考图,即旧概念中的 Character Template +- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` +- Outfit:`id`、`name`、`description`、`preview_url`、`actions` +- Action:`id`、`type`、`name`、`loop`、`fps`、`frame_count`、`frames` +- Frame:`index`、`image_url`、`duration_ms` -前端把这两者当成不同的东西: +Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.outfitId` 仅由嵌套关系推导;修改任一子项时通过 `PATCH Character` 提交完整 `character_data`。 -- 用户上传的参考图 —— 创建角色时的输入 -- AI 生成后用户选定的角色图(母版)—— `Outfit.characterTemplateUrl` +## 二、本轮明确不实现 -**后端合成了一个字段。** 07-30 评审也提到「模板」这个叫法容易与 action template 混淆,暂改称「角色图」。三方对这里是几个概念的理解需要统一。 +- 新建项目流程:只保留禁用入口,后续单独实现。 +- Workflow Editor 与生成流程:不在 Projects / 资产库模块内创建弹窗或复制生成逻辑。 +- Action Template:后端没有模块、存储或 HTTP 接口,只保留带原因的禁用入口。 +- 导出:PR #75 没有导出接口;PR #97 是尚未接入资产页的前端打包实现,只保留带原因的禁用入口。 +- 穿戴资产:当前产品定义不向用户暴露独立 Wearable 层级。 +- GIF:Character 契约只提供 Frame 图片 URL;动作卡预览使用排序后的第一帧,不伪造 GIF 字段。 ---- +## 三、仍需后端处理 -## 待确认 +这些问题不由前端降级或伪造数据规避: -- [ ] `ActionTemplateApis` 由后端提供还是前端内置 -- [ ] 母版候选几张 -- [ ] 参考图与角色图是一个字段还是两个 -- [ ] `Character.description` 前端要不要跟着存 -- [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 -- [ ] 上传模块何时提交 +1. PR #75 的 `POST /characters` DTO 接收 `name`,但路由没有把 `body.name` 传给 service;前端仍按已声明契约发送 `name`。 +2. Project / Character 路由通过 JWT,但资源查询没有按 `request.state.current_user` 强制归属隔离;前端不能代替后端完成权限边界。 +3. Project 删除没有级联 Character,可能留下孤立角色;数据一致性由后端修复。 -## 已分工 +## 四、运行前置 -- [x] 前端删除 `WorkflowRunApis`,WorkflowRun 全程由前端管理 -- [x] 前端删除 `Character.name` -- [ ] 后端增加 `jump` 动作类型 +- 配置 `VITE_API_BASE_URL`。 +- PR #75 的 Project / Character 路由要求 Bearer access token。Project、Character 实例已统一使用 `getApiAccessToken`;后续登录模块通过 `registerApiAccessTokenProvider` 提供实际 token。token 的取得、保存与刷新不属于本轮,接入前不能把未鉴权请求视为端到端可用。 +- 本模块的生产代码不包含 Mock API 或 livedemo 资产。测试只在 Vitest 中用 HTTP 服务替身验证请求、响应映射与页面行为。 diff --git a/frontend/README.md b/frontend/README.md index 2976fd5..96b9040 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,7 +25,7 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口,没有真实请求。 +`ProjectApis` 与 `CharacterApis` 负责业务 DTO 映射。项目中心、项目工作区、资产库与角色详情已接入 PR #75 的真实接口;测试数据只存在于测试环境的 HTTP 替身中。 页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。 From a59f4fc7dbb58f6f2cd7a7dca8a1d74dfd02d5cc Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 10:51:05 +0800 Subject: [PATCH 15/15] fix(project-detail): tolerate character count failures Keep the project workspace usable when the optional character count request is unavailable. Load the project and character count with Promise.allSettled, preserving project errors while applying the count only on success. Add a regression test for a failed character-list request. --- .../src/pages/project-detail/index.test.tsx | 22 +++++++++++++++++ frontend/src/pages/project-detail/index.tsx | 24 ++++++++++--------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/project-detail/index.test.tsx b/frontend/src/pages/project-detail/index.test.tsx index a453a0d..5928f7e 100644 --- a/frontend/src/pages/project-detail/index.test.tsx +++ b/frontend/src/pages/project-detail/index.test.tsx @@ -38,4 +38,26 @@ describe('ProjectDetailPage', () => { expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() expect(container.querySelector('[data-route-transition="/projects/42/assets/51"]')).toBeTruthy() }) + + it('keeps the Project workspace available when the character count request fails', async () => { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + if (request.url.includes('/characters?project_id=42')) { + return Promise.reject(new Error('characters endpoint unavailable')) + } + return backend.fetch(input, init) + }) + + render( + + + , + ) + + expect(await screen.findByRole('heading', { name: '点灯人 · MVP' })).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() + }) }) diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index 2ea2fde..b8a8d67 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -28,19 +28,21 @@ export function ProjectDetailPage() { setProject(null) setError(null) - void Promise.all([ + void Promise.allSettled([ projectApis.get(projectId), characterApis.listByProject(projectId, { page: 1, pageSize: 1 }), - ]).then( - ([nextProject, charactersPage]) => { - if (!active) return - setProject(nextProject) - setCharacterCount(charactersPage.total) - }, - () => { - if (active) setError('这个项目不存在或暂时无法读取') - }, - ) + ]).then(([projectResult, characterResult]) => { + if (!active) return + if (projectResult.status === 'rejected') { + setError('这个项目不存在或暂时无法读取') + return + } + + setProject(projectResult.value) + if (characterResult.status === 'fulfilled') { + setCharacterCount(characterResult.value.total) + } + }) return () => { active = false