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, 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} --------------------------------------------------- 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/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.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/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, 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, '删除成功') 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": {