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)让位,改顶栏尺寸时一起改。 */}
+
+
+ )
+}
+
+function isLegalSpriteLength(value: number) {
+ return Number.isSafeInteger(value) && value >= SPRITE_MIN && value <= SPRITE_MAX
+}
diff --git a/frontend/src/pages/project-create/pixel-mark.tsx b/frontend/src/pages/project-create/pixel-mark.tsx
new file mode 100644
index 00000000..f751e614
--- /dev/null
+++ b/frontend/src/pages/project-create/pixel-mark.tsx
@@ -0,0 +1,59 @@
+const GRID_SIZE = 17
+const CENTER = (GRID_SIZE - 1) / 2
+
+/**
+ * 光标笔画占用的格子,坐标以 (x, y) 记,原点在左上。
+ * 这组坐标画的是一支指向右下的箭头,纯装饰,不跟随真实鼠标。
+ */
+const CURSOR_CELLS = [
+ [8, 7],
+ [8, 8],
+ [8, 9],
+ [8, 10],
+ [8, 11],
+ [9, 8],
+ [9, 9],
+ [9, 10],
+ [10, 9],
+ [10, 10],
+ [10, 11],
+ [11, 10],
+ [11, 12],
+]
+
+/**
+ * 创建页左侧的点阵标记:一张画布方框、四角把手和一支光标。
+ * 整块是装饰,没有语义也不接受输入,因此对辅助技术隐藏。
+ * 动画全部由 CSS 承担:环号决定底噪的相位差,(x+y)%4 决定实心格的相位差,
+ * 换算成 animation-delay,让 289 个格子错峰呼吸而不是整块一起闪。
+ */
+export function ProjectCreatePixelMark() {
+ return (
+
+ {Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => {
+ const x = index % GRID_SIZE
+ const y = Math.floor(index / GRID_SIZE)
+ const frame =
+ ((y === 3 || y === 13) && x >= 3 && x <= 13) ||
+ ((x === 3 || x === 13) && y >= 3 && y <= 13)
+ const handle = (x === 2 || x === 14) && (y === 2 || y === 14)
+ const cursor = CURSOR_CELLS.some(([cellX, cellY]) => cellX === x && cellY === y)
+ const guide = (y === 6 && x >= 5 && x <= 7) || (x === 6 && y >= 5 && y <= 7)
+ const ring = Math.min(8, Math.floor(Math.hypot(x - CENTER, y - CENTER)))
+ const role = handle
+ ? 'is-active is-handle'
+ : cursor
+ ? 'is-active is-cursor'
+ : frame || guide
+ ? 'is-active'
+ : ''
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx
index 6136953c..c9fd6388 100644
--- a/frontend/src/pages/projects/index.test.tsx
+++ b/frontend/src/pages/projects/index.test.tsx
@@ -38,7 +38,7 @@ describe('ProjectsPage', () => {
expect(screen.queryByRole('link', { name: /查看角色/ })).toBeNull()
})
- it('keeps creation out of this module and deletes through the Project API', async () => {
+ it('sends creation to the project create page and deletes through the Project API', async () => {
const backend = installBackend()
render(
@@ -47,8 +47,9 @@ describe('ProjectsPage', () => {
)
expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2)
- const createButton = screen.getByRole('button', { name: '新建项目' })
- expect(createButton.hasAttribute('disabled')).toBe(true)
+ expect(screen.getByRole('link', { name: '新建项目' }).getAttribute('href')).toBe(
+ '/projects/new',
+ )
expect(screen.queryByRole('dialog', { name: '新建项目' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '删除项目 空白海岸' }))
diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx
index aebfc6c1..ff755670 100644
--- a/frontend/src/pages/projects/index.tsx
+++ b/frontend/src/pages/projects/index.tsx
@@ -76,15 +76,13 @@ export function ProjectsPage() {
项目隔离角色资产与生成规格;先选项目,再管理其资产。
-
+
{error ? (
@@ -99,7 +97,9 @@ export function ProjectsPage() {
) : projectsPage.total === 0 ? (
还没有项目
-
完成新建项目流程后,项目会显示在这里。
+
+ 从右上角新建一个项目,之后它会显示在这里。
+
) : (
diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts
index 3931365c..f6cdf526 100644
--- a/frontend/src/test/project-assets-backend.ts
+++ b/frontend/src/test/project-assets-backend.ts
@@ -1,4 +1,20 @@
-const projectDtos = [
+/** 后端 ProjectOut 的形状;写死成显式类型,免得 fixture 的字面量把可空字段收窄。 */
+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 projectDtos: ProjectDto[] = [
{
id: 42,
user_id: 7,
@@ -188,6 +204,41 @@ export function createProjectAssetsBackend({
return listResponse(projects.slice(start, start + pageSize), page, pageSize, projects.length)
}
+ if (request.method === 'POST' && url.pathname === '/projects') {
+ const body = (await request.json()) as {
+ 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
+ }
+ if (projects.some((item) => item.project_name === body.project_name)) {
+ return new Response(JSON.stringify({ code: 400, message: '项目名称已存在', data: null }), {
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+ const created = {
+ id: 4_242,
+ // 后端从 access token 取归属,请求体里没有 user_id;这里跟 fixture 用同一个用户。
+ user_id: 7,
+ workflow_id: body.workflow_id ?? null,
+ project_name: body.project_name,
+ character_perspective: body.character_perspective,
+ directional_movement: body.directional_movement,
+ sprite_width: body.sprite_width,
+ sprite_height: body.sprite_height,
+ game_style: body.game_style ?? null,
+ sprite_sample_url: body.sprite_sample_url ?? null,
+ create_at: '2026-08-06T00:00:00Z',
+ update_at: '2026-08-06T00:00:00Z',
+ }
+ projects = [...projects, created]
+ return response(created)
+ }
+
if (url.pathname.startsWith('/projects/')) {
const projectId = Number(url.pathname.split('/').at(-1))
const project = projects.find((item) => item.id === projectId)