diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 34d7cb5..b65a2a5 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 只为高亮当前项。外壳也不统一夹居中容器,宽度与留白由页面自己决定。 ### 依赖规则 @@ -88,18 +88,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 f64163f..052cfc5 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` 只提供顶栏,不再统一夹一个居中容器。 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' 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') + }) +}) 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', + }) + }, } 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' 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') + }) +}) 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', + }) + }, +} 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; + } +} 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/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.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() + }) +}) 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} + + )} +
+ ) +} 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..5928f7e --- /dev/null +++ b/frontend/src/pages/project-detail/index.test.tsx @@ -0,0 +1,63 @@ +// @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() + }) + + 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 911b1e4..b8a8d67 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -1,13 +1,140 @@ -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.allSettled([ + projectApis.get(projectId), + characterApis.listByProject(projectId, { page: 1, pageSize: 1 }), + ]).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 + } + }, [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.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) + }) +}) 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}”?

+

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

+
+ + +
+
+
+ ) +} 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 ( + + ) +} 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 } +}