From ba1e2a970decca1afc8ab2bb582d193eb213cc0a Mon Sep 17 00:00:00 2001 From: huyan Date: Sun, 23 Aug 2026 20:42:24 +0800 Subject: [PATCH 1/5] feat(projects): add project rename endpoint Project cards need a persistent way to change names. Add ownership checks, duplicate handling, and service-level persistence. Project renames now return the updated resource through PATCH. --- .../windup_app/server/project/interface.py | 6 ++++ .../src/windup_app/server/project/service.py | 7 ++++ .../app/src/windup_app/web/api/project.py | 35 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py index ed82ca1c..630a6097 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -54,6 +54,12 @@ def list_project_previews( ) -> dict[int, str | None]: """一次查询当前页项目的资产预览,结果包含所有传入项目 ID。""" + @abstractmethod + def rename_project( + self, session: Session, project: Project, *, project_name: str + ) -> Project: + """修改已完成归属校验的项目名称。""" + @abstractmethod def delete_project(self, session: Session, project_id: int) -> bool: """删除项目并返回是否找到。""" diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py index 3c039992..28228ae9 100644 --- a/backend/packages/app/src/windup_app/server/project/service.py +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -126,6 +126,13 @@ def list_project_previews( ) return previews + def rename_project( + self, session: Session, project: Project, *, project_name: str + ) -> Project: + project.project_name = project_name + session.flush() + return project + def delete_project(self, session: Session, project_id: int) -> bool: project = session.get(Project, project_id) if project is None: diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py index 5058821c..969529c6 100644 --- a/backend/packages/app/src/windup_app/web/api/project.py +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -34,6 +34,12 @@ class ProjectCreate(BaseModel): sprite_sample_url: str | None = None +class ProjectRename(BaseModel): + """重命名项目请求。""" + + project_name: str = Field(min_length=1, max_length=20) + + class ProjectOut(BaseModel): """项目响应。""" @@ -129,6 +135,35 @@ def get_project( return Response.success(ProjectOut.model_validate(project)) +@router.patch("/{project_id}", response_model=Response[ProjectOut]) +def rename_project( + project_id: int, + body: ProjectRename, + request: Request, + session: Session = Depends(get_session), +) -> Response[ProjectOut]: + user_id = request.state.current_user.id + project = service.get_project(session, project_id, for_update=True) + if project is None or project.user_id != user_id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + if project.project_name == body.project_name: + return Response.success( + ProjectOut.model_validate(project), message="重命名成功" + ) + if service.project_name_exists( + session, user_id=user_id, project_name=body.project_name + ): + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) + try: + project = service.rename_project( + session, project, project_name=body.project_name + ) + except IntegrityError: + session.rollback() + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None + return Response.success(ProjectOut.model_validate(project), message="重命名成功") + + @router.delete("/{project_id}", response_model=Response[None]) def delete_project( project_id: int, From 4d0d7e7c6350692029f4c68428bf190fe5189bf7 Mon Sep 17 00:00:00 2001 From: huyan Date: Sun, 23 Aug 2026 20:42:49 +0800 Subject: [PATCH 2/5] test(projects): cover project rename rules The rename endpoint must preserve ownership and naming constraints. Cover successful persistence, duplicate rejection, and cross-user access. These cases protect the public project rename contract. --- backend/tests/test_project_api.py | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py index b69a77e2..9be936b0 100644 --- a/backend/tests/test_project_api.py +++ b/backend/tests/test_project_api.py @@ -227,6 +227,58 @@ def record_character_select( assert len(statements) == 1 +# -- PATCH /projects/{id} ---------------------------------------------------- + + +def test_rename_success_persists_the_new_name(auth_client): + created = auth_client.post( + "/projects", json=_payload(project_name="重命名前") + ).json()["data"] + + resp = auth_client.patch( + f"/projects/{created['id']}", json={"project_name": "重命名后"} + ) + + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "重命名成功" + assert body["data"]["project_name"] == "重命名后" + persisted = auth_client.get(f"/projects/{created['id']}").json()["data"] + assert persisted["project_name"] == "重命名后" + + +def test_rename_duplicate_name_returns_400(auth_client): + auth_client.post("/projects", json=_payload(project_name="已存在")) + created = auth_client.post( + "/projects", json=_payload(project_name="待修改") + ).json()["data"] + + resp = auth_client.patch( + f"/projects/{created['id']}", json={"project_name": "已存在"} + ) + + assert resp.json()["code"] == 400 + assert resp.json()["message"] == "项目名称已存在" + persisted = auth_client.get(f"/projects/{created['id']}").json()["data"] + assert persisted["project_name"] == "待修改" + + +def test_rename_rejects_another_users_project(auth_client, auth_client_b): + created = auth_client.post( + "/projects", json=_payload(project_name="我的项目") + ).json()["data"] + + resp = auth_client_b.patch( + f"/projects/{created['id']}", json={"project_name": "越权改名"} + ) + + assert resp.json()["code"] == 404 + assert ( + auth_client.get(f"/projects/{created['id']}").json()["data"]["project_name"] + == "我的项目" + ) + + # -- DELETE /projects/{id} --------------------------------------------------- From 0f4a83f7e03ffd5b5d00de18b272d6367d749574 Mon Sep 17 00:00:00 2001 From: huyan Date: Sun, 23 Aug 2026 20:43:17 +0800 Subject: [PATCH 3/5] docs(api): expose project rename contract The generated schema must reflect the new project capability. Add the rename request model and PATCH operation to OpenAPI. API consumers can now discover the project rename endpoint. --- openapi.json | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/openapi.json b/openapi.json index 053c9fbe..5c41ea01 100644 --- a/openapi.json +++ b/openapi.json @@ -2190,6 +2190,22 @@ "title": "ProjectOut", "type": "object" }, + "ProjectRename": { + "description": "重命名项目请求。", + "properties": { + "project_name": { + "maxLength": 20, + "minLength": 1, + "title": "Project Name", + "type": "string" + } + }, + "required": [ + "project_name" + ], + "title": "ProjectRename", + "type": "object" + }, "RefreshRequest": { "description": "刷新 token 请求。", "properties": { @@ -4402,6 +4418,56 @@ "tags": [ "projects" ] + }, + "patch": { + "operationId": "rename_project_projects__project_id__patch", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "title": "Project Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_ProjectOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Rename Project", + "tags": [ + "projects" + ] } }, "/quota/balance": { From 2af3b28795278560382f699f041d654a84890099 Mon Sep 17 00:00:00 2001 From: huyan Date: Sun, 23 Aug 2026 20:43:47 +0800 Subject: [PATCH 4/5] feat(projects): add persistent card actions Project cards hide their only action until hover and only expose deletion. Replace it with a persistent warm-canvas menu and add the rename dialog. Users can discover, rename, or delete projects without changing card navigation. --- frontend/src/entities/project/index.ts | 24 ++- frontend/src/pages/projects/index.tsx | 205 ++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 7 deletions(-) diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 6c015e58..0d3c0698 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -75,11 +75,12 @@ export const DIRECTIONAL_MOVEMENT: Record = { 'eight-way': '八向', } -/** Project 对应的一组后端接口。PR #75 未提供更新端点,因此这里不声明 update。 */ +/** Project 对应的一组后端接口。 */ export interface ProjectApis { list(query?: ProjectPageQuery): Promise> get(id: Project['id']): Promise create(input: CreateProjectInput): Promise + rename(id: Project['id'], name: string): Promise remove(id: Project['id']): Promise } @@ -218,6 +219,27 @@ export const projectApis: ProjectApis = { } }, + async rename(id, name) { + try { + return mapProject( + await getApiClient().request(`/projects/${encodeURIComponent(id)}`, { + method: 'PATCH', + json: { project_name: name }, + }), + ) + } catch (error) { + if ( + error instanceof ApiError && + error.kind === 'business' && + error.code === 400 && + error.message === '项目名称已存在' + ) { + throw new ProjectNameConflictError({ cause: error }) + } + throw error + } + }, + async remove(id) { try { await getApiClient().request(`/projects/${encodeURIComponent(id)}`, { diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index a9228cf5..b97e4311 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type CSSProperties } from 'react' +import { DotsThree, PencilSimple, Trash } from '@phosphor-icons/react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import { Link } from 'react-router' import assetLibraryArtwork from '@/assets/workspace/asset-library.png' @@ -7,6 +8,7 @@ import { DIRECTIONAL_MOVEMENT, projectApis, ProjectHasCharactersError, + ProjectNameConflictError, type Project, } from '@/entities' import type { Paged } from '@/shared/pagination' @@ -25,6 +27,9 @@ type ProjectPreviewState = { status: 'ready'; url: string } | { status: 'empty' export function ProjectsPage() { const [pageNumber, setPageNumber] = useState(1) const [projectsPage, setProjectsPage] = useState | null>(null) + const [renameTarget, setRenameTarget] = useState(null) + const [renaming, setRenaming] = useState(false) + const [renameError, setRenameError] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) @@ -73,6 +78,31 @@ export function ProjectsPage() { } } + async function renameProject(project: Project, name: string) { + setRenaming(true) + setRenameError(null) + try { + const renamed = await projectApis.rename(project.id, name) + setProjectsPage((current) => + current + ? { + ...current, + items: current.items.map((item) => + item.id === project.id ? { ...renamed, previewUrl: item.previewUrl } : item, + ), + } + : current, + ) + setRenameTarget(null) + } catch (error) { + setRenameError( + error instanceof ProjectNameConflictError ? error.message : '项目暂时无法重命名', + ) + } finally { + setRenaming(false) + } + } + return (
@@ -92,6 +122,10 @@ export function ProjectsPage() { { + setRenameError(null) + setRenameTarget(project) + }} onDelete={setDeleteTarget} /> ) : null} @@ -106,6 +140,19 @@ export function ProjectsPage() { ) : null}
+ {renameTarget ? ( + { + setRenameError(null) + setRenameTarget(null) + }} + onConfirm={(name) => renameProject(renameTarget, name)} + /> + ) : null} + {deleteTarget ? ( void onDelete: (project: Project) => void }) { return ( @@ -209,6 +258,7 @@ function ProjectGallery({ project={project} preview={projectPreview(project)} motionOrder={index} + onRename={() => onRename(project)} onDelete={() => onDelete(project)} /> ))} @@ -226,11 +276,13 @@ function ProjectGalleryTile({ project, preview, motionOrder, + onRename, onDelete, }: { project: Project preview: ProjectPreviewState motionOrder: number + onRename: () => void onDelete: () => void }) { const updatedAt = new Intl.DateTimeFormat('zh-CN', { @@ -264,15 +316,81 @@ function ProjectGalleryTile({ {project.gameStyle || '未设置游戏风格'}

+ + + ) +} + +function ProjectActions({ + project, + onRename, + onDelete, +}: { + project: Project + onRename: () => void + onDelete: () => void +}) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + function closeOnOutsidePress(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false) + } + function closeOnEscape(event: KeyboardEvent) { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', closeOnOutsidePress) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('pointerdown', closeOnOutsidePress) + document.removeEventListener('keydown', closeOnEscape) + } + }, [open]) + + return ( +
- + {open ? ( +
+ + +
+ ) : null} +
) } @@ -356,6 +474,81 @@ function ProjectPreviewMessage({ ) } +function RenameProjectDialog({ + project, + pending, + error, + onClose, + onConfirm, +}: { + project: Project + pending: boolean + error: string | null + onClose: () => void + onConfirm: (name: string) => Promise +}) { + const [name, setName] = useState(project.name) + const normalizedName = name.trim() + + return ( +
+
+

重命名项目

+

项目名称会同步更新到项目中心。

+
{ + event.preventDefault() + if (normalizedName) void onConfirm(normalizedName) + }} + > + + setName(event.target.value)} + className="mt-2 w-full rounded-xl border border-app-line bg-app-surface px-3.5 py-2.5 text-sm text-app-ink outline-none transition focus:border-app-ink" + /> +
+ + {error} + + {name.length}/20 +
+
+ + +
+
+
+
+ ) +} + function DeleteProjectDialog({ project, pending, From 3648f77754fc2132afb692c1f1dad76b46920d50 Mon Sep 17 00:00:00 2001 From: huyan Date: Sun, 23 Aug 2026 20:44:17 +0800 Subject: [PATCH 5/5] test(projects): cover persistent card actions The project card menu changes both rename and delete entry points. Cover API mapping, disclosure behavior, rename persistence, and delete regressions. The fixture now models project PATCH requests and duplicate names. --- frontend/src/entities/project/index.test.ts | 23 +++++++++ frontend/src/pages/projects/index.test.tsx | 53 ++++++++++++++++++++- frontend/src/test/project-assets-backend.ts | 18 +++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts index f137bc6e..322ef23d 100644 --- a/frontend/src/entities/project/index.test.ts +++ b/frontend/src/entities/project/index.test.ts @@ -117,6 +117,25 @@ describe('projectApis', () => { expect(requestUrl).toBe('https://api.windup.test/projects/42') }) + it('renames one Project through the backend resource path', async () => { + let request: Request | undefined + const renamedDto = { ...projectDto, project_name: '新项目名' } + const { projectApis } = await loadProjectApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(renamedDto) + }) + + await expect(projectApis.rename('42', '新项目名')).resolves.toMatchObject({ + id: '42', + name: '新项目名', + }) + expect(request?.url).toBe('https://api.windup.test/projects/42') + expect(request?.method).toBe('PATCH') + await expect(request?.json()).resolves.toEqual({ + project_name: '新项目名', + }) + }) + 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) => { @@ -160,6 +179,10 @@ describe('projectApis', () => { spriteSize: { width: 256, height: 256 }, }), ).rejects.toBeInstanceOf(ProjectNameConflictError) + + await expect(projectApis.rename('42', '点灯人')).rejects.toBeInstanceOf( + ProjectNameConflictError, + ) }) it('maps the backend in-use project contract to a stable domain error', async () => { diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index 11150196..a9b3633d 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -201,6 +201,53 @@ describe('ProjectsPage', () => { ).toHaveLength(0) }) + it('keeps project actions discoverable and persists a renamed project', async () => { + const backend = installBackend() + render( + + + + + , + ) + + expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) + const actions = screen.getByRole('button', { name: '项目操作 空白海岸' }) + expect(actions.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(actions) + + expect(actions.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('group', { name: '空白海岸的项目操作' })).toBeTruthy() + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('group', { name: '空白海岸的项目操作' })).toBeNull() + + fireEvent.click(actions) + const actionsGroup = screen.getByRole('group', { + name: '空白海岸的项目操作', + }) + fireEvent.click(within(actionsGroup).getByRole('button', { name: '重命名项目' })) + + const dialog = screen.getByRole('dialog', { name: '重命名项目' }) + const nameInput = within(dialog).getByRole('textbox', { name: '项目名称' }) + expect(nameInput.getAttribute('value')).toBe('空白海岸') + fireEvent.change(nameInput, { target: { value: '雾港' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '保存名称' })) + + expect(await screen.findByRole('link', { name: '打开项目 雾港' })).toBeTruthy() + expect(screen.queryByRole('dialog', { name: '重命名项目' })).toBeNull() + const renameRequest = backend.requests.find( + (request) => request.method === 'PATCH' && request.url.endsWith('/projects/99'), + ) + expect(renameRequest).toBeTruthy() + await expect(renameRequest?.json()).resolves.toEqual({ + project_name: '雾港', + }) + }) + it('sends creation to the project create page and deletes through the Project API', async () => { const backend = installBackend() render( @@ -217,7 +264,8 @@ describe('ProjectsPage', () => { ) expect(screen.queryByRole('dialog', { name: '新建项目' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '删除项目 空白海岸' })) + fireEvent.click(screen.getByRole('button', { name: '项目操作 空白海岸' })) + fireEvent.click(screen.getByRole('button', { name: '删除项目' })) fireEvent.click(screen.getByRole('button', { name: '确认删除项目' })) await waitFor(() => { @@ -243,7 +291,8 @@ describe('ProjectsPage', () => { ) expect(await screen.findByRole('link', { name: '打开项目 点灯人 · MVP' })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '删除项目 点灯人 · MVP' })) + fireEvent.click(screen.getByRole('button', { name: '项目操作 点灯人 · MVP' })) + fireEvent.click(screen.getByRole('button', { name: '删除项目' })) fireEvent.click(screen.getByRole('button', { name: '确认删除项目' })) expect(await screen.findByText('项目下仍有角色,无法删除')).toBeTruthy() diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts index 093ebbf9..433b9cce 100644 --- a/frontend/src/test/project-assets-backend.ts +++ b/frontend/src/test/project-assets-backend.ts @@ -244,6 +244,24 @@ export function createProjectAssetsBackend({ 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 === 'PATCH' && project) { + const body = (await request.json()) as { project_name: string } + if ( + projects.some((item) => item.id !== projectId && item.project_name === body.project_name) + ) { + return new Response( + JSON.stringify({ + code: 400, + message: '项目名称已存在', + data: null, + }), + { headers: { 'content-type': 'application/json' } }, + ) + } + project.project_name = body.project_name + project.update_at = '2026-08-07T00:00:00Z' + return response(project, '重命名成功') + } if (request.method === 'DELETE' && project) { projects = projects.filter((item) => item.id !== projectId) return response(null, '删除成功')