diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 5814fa69..9aa32e8a 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -15,6 +15,8 @@ `ProjectOut` 的 `user_id`、`workflow_id`、`project_name`、`character_perspective`、`directional_movement`、精灵宽高、画风、参考图和时间字段,均在 `entities/project` 内显式映射为 camelCase。PR #75 没有项目更新端点,因此前端不声明 `ProjectApis.update`。 +项目归属由后端从 access token 取:`ProjectCreate` 不含 `user_id`,`/projects` 各路由统一读 `request.state.current_user.id`,且 `/projects` 不在鉴权白名单里。因此 `CreateProjectInput` 没有 ownerId,请求体也不带 `user_id`——带了等于宣称调用方可以替别人建项目,那正是后端刚修掉的越权口子。`/projects/new` 的创建入口只看有没有 access token(`getApiAccessToken()`);登录模块尚未接入时保持禁用并写明需要登录。 + 后端枚举按下表映射: | 后端值 | `character_perspective` | `directional_movement` | @@ -54,7 +56,6 @@ Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.ou ## 二、本轮明确不实现 -- 新建项目流程:只保留禁用入口,后续单独实现。 - Workflow Editor 与生成流程:不在 Projects / 资产库模块内创建弹窗或复制生成逻辑。 - Action Template:后端没有模块、存储或 HTTP 接口,只保留带原因的禁用入口。 - 导出:PR #75 没有导出接口;PR #97 是尚未接入资产页的前端打包实现,只保留带原因的禁用入口。 diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index a7c1c323..9b5c47d8 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -6,6 +6,7 @@ import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' import { PlaytestPage } from '@/pages/playtest' import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' @@ -34,6 +35,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts index d630e14b..559b563a 100644 --- a/frontend/src/entities/project/index.test.ts +++ b/frontend/src/entities/project/index.test.ts @@ -82,7 +82,6 @@ describe('projectApis', () => { }) await projectApis.create({ - ownerId: '7', workflowId: '9', name: '点灯人', perspective: 'isometric', @@ -93,8 +92,8 @@ describe('projectApis', () => { }) expect(request?.method).toBe('POST') + // 请求体不含 user_id:归属由后端从 access token 取,见 CreateProjectInput 的注释。 await expect(request?.json()).resolves.toEqual({ - user_id: 7, workflow_id: 9, project_name: '点灯人', character_perspective: 3, diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 79152358..7ad4ecf2 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -16,9 +16,12 @@ export interface Project { updatedAt: string } -/** 后端创建 Project 需要的完整字段;创建流程本身不属于资产库页面。 */ +/** + * 后端创建 Project 需要的完整字段。 + * 这里没有 ownerId:归属由后端从 access token 里取(`ProjectCreate` 不含 user_id), + * 请求体再带一个用户 ID 就等于宣称调用方可以替别人建项目。 + */ export interface CreateProjectInput { - ownerId: string workflowId?: string | null name: string perspective: CharacterPerspective @@ -163,7 +166,6 @@ export const projectApis: ProjectApis = { const dto = await getApiClient().request('/projects', { method: 'POST', json: { - user_id: toBackendId(input.ownerId, 'ownerId'), workflow_id: input.workflowId === undefined ? undefined diff --git a/frontend/src/index.css b/frontend/src/index.css index 42c544ae..fd6020e9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -104,7 +104,151 @@ body, .projects-dialog-backdrop, .projects-dialog-panel, .route-transition, - .action-reveal { + .action-reveal, + .project-pixel-mark i { animation: none; } } + +@keyframes project-pixel-field { + 0%, + 58%, + 100% { + opacity: 0.5; + transform: scale(0.84); + } + + 16% { + opacity: 0.96; + transform: scale(1.08); + } +} + +@keyframes project-pixel-frame { + 0%, + 100% { + opacity: 0.72; + transform: scale(0.86); + } + + 38% { + opacity: 1; + transform: scale(1.08); + } +} + +@keyframes project-pixel-handle { + 0%, + 24%, + 100% { + transform: scale(0.82); + } + + 42%, + 58% { + transform: scale(1.18); + } +} + +@keyframes project-pixel-cursor { + 0%, + 14%, + 100% { + opacity: 0.28; + transform: translate(-5px, -5px) scale(0.86); + } + + 38% { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} + +/* + 创建页左侧的装饰点阵。289 个格子共用四组动画,靠 animation-delay 错峰: + pixel-ring-N 是格子到中心的距离环,管底噪;pixel-phase-N 是 (x+y)%4,管实心笔画。 + 两组延迟都是负值,页面一进来就处在动画中段,不会先静止再启动。 +*/ +.project-pixel-mark { + display: grid; + grid-template-columns: repeat(17, 5px); + grid-template-rows: repeat(17, 5px); + gap: 8px; + place-content: center; + width: 248px; + height: 248px; +} + +.project-pixel-mark i { + width: 5px; + height: 5px; + border-radius: 1px; + background: #bfc6bf; + opacity: 0.62; + box-shadow: 0 0 0 1px rgb(255 255 255 / 18%); + animation: project-pixel-field 4.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.project-pixel-mark .pixel-ring-1 { + animation-delay: -0.14s; +} + +.project-pixel-mark .pixel-ring-2 { + animation-delay: -0.28s; +} + +.project-pixel-mark .pixel-ring-3 { + animation-delay: -0.42s; +} + +.project-pixel-mark .pixel-ring-4 { + animation-delay: -0.56s; +} + +.project-pixel-mark .pixel-ring-5 { + animation-delay: -0.7s; +} + +.project-pixel-mark .pixel-ring-6 { + animation-delay: -0.84s; +} + +.project-pixel-mark .pixel-ring-7 { + animation-delay: -0.98s; +} + +.project-pixel-mark .pixel-ring-8 { + animation-delay: -1.12s; +} + +.project-pixel-mark i.is-active { + background: #202120; + opacity: 0.9; + box-shadow: none; + animation: project-pixel-frame 3.4s ease-in-out infinite; +} + +.project-pixel-mark i.is-active.pixel-phase-1 { + animation-delay: -0.18s; +} + +.project-pixel-mark i.is-active.pixel-phase-2 { + animation-delay: -0.36s; +} + +.project-pixel-mark i.is-active.pixel-phase-3 { + animation-delay: -0.54s; +} + +.project-pixel-mark i.is-handle { + background: #050505; + opacity: 1; + box-shadow: 0 0 0 4px rgb(17 18 17 / 9%); + animation: project-pixel-handle 3.8s ease-in-out infinite; +} + +.project-pixel-mark i.is-cursor { + background: #090a09; + opacity: 1; + animation: project-pixel-cursor 2.8s cubic-bezier(0.22, 1, 0.36, 1) infinite; +} diff --git a/frontend/src/pages/home/index.test.tsx b/frontend/src/pages/home/index.test.tsx index ce7257f9..23131408 100644 --- a/frontend/src/pages/home/index.test.tsx +++ b/frontend/src/pages/home/index.test.tsx @@ -18,7 +18,9 @@ describe('HomePage', () => { expect(screen.getByRole('heading', { name: /真正登场/ })).toBeTruthy() expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start') expect(screen.getByText('工作流画布')).toBeTruthy() - expect(screen.getByRole('link', { name: '创建新项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '创建新项目' }).getAttribute('href')).toBe( + '/projects/new', + ) expect(screen.getByRole('link', { name: '继续已有项目' }).getAttribute('href')).toBe( '/projects', ) diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index eae909b7..5e6c906e 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -87,8 +87,9 @@ export function HomePage() { /> {/* 画布入口必须先确定角色挂在哪个项目下,所以动作条悬停后分成新建与继续两条。 - 两条现在都落在 /projects 占位页;建项目、建 workflow run 再进 - /workflow-editor/:runId 的真实链路等后端接口对齐后接上。 + 新建直达创建页;继续先落项目中心选项目,等项目级 WorkflowRun 历史页面就位后 + 再指到那里。两条都还进不了 /workflow-editor/:runId——建 workflow run 需要 + WorkflowController,它目前只有接口没有实现。 */} diff --git a/frontend/src/pages/project-create/index.test.tsx b/frontend/src/pages/project-create/index.test.tsx new file mode 100644 index 00000000..b9b05f11 --- /dev/null +++ b/frontend/src/pages/project-create/index.test.tsx @@ -0,0 +1,186 @@ +// @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 { registerApiAccessTokenProvider } from '@/shared/api' +import { createProjectAssetsBackend } from '@/test/project-assets-backend' + +const revokeProviders: Array<() => void> = [] + +afterEach(() => { + cleanup() + while (revokeProviders.length) revokeProviders.pop()?.() + 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 +} + +/** 登录模块尚未落地,测试里用同一个 provider 边界冒充已签发的 access token。 */ +function signIn(accessToken = 'access-token-for-test') { + revokeProviders.push(registerApiAccessTokenProvider(() => accessToken)) +} + +function renderProjectCreate() { + return render( + + + , + ) +} + +function creationRequests(backend: ReturnType) { + return backend.requests.filter( + (request) => request.method === 'POST' && new URL(request.url).pathname === '/projects', + ) +} + +describe('ProjectCreatePage', () => { + it('按项目契约提交表单并进入创建出的项目', async () => { + const backend = installBackend() + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) + fireEvent.change(screen.getByLabelText('游戏视角'), { target: { value: 'top-down' } }) + fireEvent.change(screen.getByLabelText('朝向'), { target: { value: 'eight-way' } }) + fireEvent.click(screen.getByRole('button', { name: '512 × 512' })) + fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } }) + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + + expect(await screen.findByRole('heading', { name: '角色' })).toBeTruthy() + const [request] = creationRequests(backend) + expect(request.headers.get('authorization')).toBe('Bearer access-token-for-test') + const body = (await request.json()) as Record + expect(body).toMatchObject({ + project_name: '雾港来信', + character_perspective: 2, + directional_movement: 3, + sprite_width: 512, + sprite_height: 512, + game_style: '低饱和像素绘本', + }) + // 归属由后端从 JWT 取;请求体再带 user_id 就等于宣称可以替别人建项目。 + expect(Object.hasOwn(body, 'user_id')).toBe(false) + }) + + it('没有登录凭证时禁用创建并说明原因', async () => { + const backend = installBackend() + renderProjectCreate() + + const submit = await screen.findByRole('button', { name: '创建项目' }) + expect(submit.hasAttribute('disabled')).toBe(true) + expect(screen.getByText(/登录/)).toBeTruthy() + expect(creationRequests(backend)).toHaveLength(0) + }) + + it('名称超过 20 字时在提交前拦下', async () => { + const backend = installBackend() + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾'.repeat(21) } }) + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + + expect(await screen.findByText('项目名称最多 20 个字')).toBeTruthy() + expect(creationRequests(backend)).toHaveLength(0) + }) + + it('名称重复时保留已填内容并显示后端给出的原因', async () => { + installBackend() + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '点灯人 · MVP' } }) + fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } }) + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + + expect((await screen.findByRole('alert')).textContent).toBe('项目名称已存在') + expect((screen.getByLabelText('画风约束') as HTMLTextAreaElement).value).toBe('低饱和像素绘本') + }) + + it('连续点击创建只发出一次请求', async () => { + const backend = installBackend() + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) + const submit = screen.getByRole('button', { name: '创建项目' }) + fireEvent.click(submit) + fireEvent.click(submit) + + await waitFor(() => expect(creationRequests(backend).length).toBeGreaterThan(0)) + expect(creationRequests(backend)).toHaveLength(1) + }) + it('名称留空时在提交前拦下', async () => { + const backend = installBackend() + signIn() + renderProjectCreate() + + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + + expect((await screen.findByRole('alert')).textContent).toBe('请填写项目名称') + expect(creationRequests(backend)).toHaveLength(0) + }) + + it('精灵宽高越界时在提交前拦下', async () => { + const backend = installBackend() + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) + fireEvent.change(screen.getByLabelText('宽度(像素)'), { target: { value: '16' } }) + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + + expect((await screen.findByRole('alert')).textContent).toBe( + '精灵宽高需要是 32 到 2048 之间的整数', + ) + expect(creationRequests(backend)).toHaveLength(0) + }) + + it('传输失败时收敛成一句统一文案', async () => { + installBackend() + vi.stubGlobal('fetch', () => Promise.reject(new TypeError('offline'))) + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + + expect((await screen.findByRole('alert')).textContent).toBe('项目暂时无法创建') + }) + + it('改动任一字段后撤掉上一次的错误', async () => { + installBackend() + signIn() + renderProjectCreate() + + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + expect(await screen.findByRole('alert')).toBeTruthy() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) + + expect(screen.queryByRole('alert')).toBeNull() + }) + it('点尺寸预设后撤掉上一次的错误', async () => { + installBackend() + signIn() + renderProjectCreate() + + fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) + fireEvent.change(screen.getByLabelText('宽度(像素)'), { target: { value: '16' } }) + fireEvent.click(screen.getByRole('button', { name: '创建项目' })) + expect(await screen.findByRole('alert')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '256 × 256' })) + + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/frontend/src/pages/project-create/index.tsx b/frontend/src/pages/project-create/index.tsx new file mode 100644 index 00000000..3111fb9b --- /dev/null +++ b/frontend/src/pages/project-create/index.tsx @@ -0,0 +1,282 @@ +import { useRef, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router' + +import { + CHARACTER_PERSPECTIVE, + DIRECTIONAL_MOVEMENT, + projectApis, + type CharacterPerspective, + type DirectionalMovement, +} from '@/entities' +import { ApiError, getApiAccessToken } from '@/shared/api' +import { ProjectCreatePixelMark } from './pixel-mark' + +/** + * 项目名称上限跟随 main 上 `windup_project.project_name` 的 `String(20)`。 + * 尚未合入的两个后端 PR 在这里不一致(#75 限 20、#126 放宽到 64),取更严的一边, + * 哪条先落地都能提交成功;放宽是加法,反过来会立刻退回重名与截断。 + */ +const NAME_MAX_LENGTH = 20 + +/** + * 精灵宽高的合法区间。写在前端是因为 main 的后端还没有 `/projects` 路由, + * 取值依据是 issue #141 的产品规则(与未合入的 #75 / #126 请求校验一致)。 + */ +const SPRITE_MIN = 32 +const SPRITE_MAX = 2048 + +/** 常用档位只是快捷填充,用户仍可以填这三档之外的任意合法宽高。 */ +const SPRITE_PRESETS = [128, 256, 512] + +/** + * 画风约束的长度上限只存在于前端:后端 `game_style` 是没有长度约束的 Text。 + * 定这个数是为了让它维持在「一句风格描述」的量级,不是契约要求。 + */ +const GAME_STYLE_MAX_LENGTH = 240 + +/** 创建真实项目;首页画布入口与项目中心的新建按钮共用这一页。 */ +export function ProjectCreatePage() { + const navigate = useNavigate() + /** + * 能不能建项目只取决于有没有登录:后端 `/projects` 不在鉴权白名单里, + * 归属也从 access token 里取。登录模块尚未接入时没有人注册 provider, + * 这里拿到 null,入口保持禁用并写明原因,不塞占位值让它看起来能用。 + */ + const signedIn = Boolean(getApiAccessToken()) + + const [name, setName] = useState('') + const [perspective, setPerspective] = useState('side') + const [directionalMovement, setDirectionalMovement] = useState('single') + const [spriteWidth, setSpriteWidth] = useState('256') + const [spriteHeight, setSpriteHeight] = useState('256') + const [gameStyle, setGameStyle] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + /** 同一批事件里 submitting 还是上一次 render 的值,按钮变灰之前的重复提交只能靠这个挡。 */ + const inFlight = useRef(false) + + async function submit(event: FormEvent) { + event.preventDefault() + if (inFlight.current || !signedIn) return + + const trimmedName = name.trim() + const width = Number(spriteWidth) + const height = Number(spriteHeight) + if (!trimmedName) return setError('请填写项目名称') + if (trimmedName.length > NAME_MAX_LENGTH) + return setError(`项目名称最多 ${NAME_MAX_LENGTH} 个字`) + if (![width, height].every(isLegalSpriteLength)) { + return setError(`精灵宽高需要是 ${SPRITE_MIN} 到 ${SPRITE_MAX} 之间的整数`) + } + + inFlight.current = true + setSubmitting(true) + setError(null) + try { + const project = await projectApis.create({ + name: trimmedName, + perspective, + directionalMovement, + spriteSize: { width, height }, + gameStyle: gameStyle.trim() || null, + }) + navigate(`/projects/${project.id}`) + } catch (cause) { + // 业务错误(如重名)由后端给出具体原因,原样转达;传输错误对用户没有信息量,收敛成一句。 + setError( + cause instanceof ApiError && cause.kind === 'business' ? cause.message : '项目暂时无法创建', + ) + inFlight.current = false + setSubmitting(false) + } + } + + return ( +
+ + + {/* pt-24 与 PageContainer 同源:给 fixed 顶栏(top-3.5 加最小高 3.625rem)让位,改顶栏尺寸时一起改。 */} +
+
setError(null)} + className="mx-auto grid max-w-2xl gap-7" + > +
+

+ PROJECT SETUP +

+

新建项目

+

+ 只确定项目级的题材与规格;角色和动作在项目内再逐个创建。 +

+
+ +
+ + setName(event.target.value)} + className="rounded-xl border border-[#d5d9d2] bg-[#f5f5f2] px-4 py-3 text-sm outline-none focus-visible:border-[#8f978d]" + /> + + 最多 {NAME_MAX_LENGTH} 个字,同一账号下不能重名。 + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ 精灵尺寸 +
+ {SPRITE_PRESETS.map((preset) => ( + + ))} +
+
+
+ + setSpriteWidth(event.target.value)} + className="rounded-xl border border-[#d5d9d2] bg-[#f5f5f2] px-4 py-3 text-sm outline-none focus-visible:border-[#8f978d]" + /> +
+
+ + setSpriteHeight(event.target.value)} + className="rounded-xl border border-[#d5d9d2] bg-[#f5f5f2] px-4 py-3 text-sm outline-none focus-visible:border-[#8f978d]" + /> +
+
+
+ +
+ +