From cb88a6901219c5a489eced946c8fe87427f3dedb Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 13 Aug 2026 18:45:20 +0800 Subject: [PATCH] fix: support safe project deletion --- .../20260813-project-delete-fix-d3633d68.md | 70 ++++++ server/internal/httpapi/admin.go | 44 ++++ server/internal/httpapi/http_logging.go | 1 + .../project_delete_integration_test.go | 238 ++++++++++++++++++ server/internal/httpapi/server.go | 1 + web/src/api.test.ts | 10 + web/src/api.ts | 3 + web/src/pages/AdminPage.test.tsx | 52 +++- web/src/pages/AdminPage.tsx | 32 +++ web/src/styles.css | 10 + 10 files changed, 458 insertions(+), 3 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260813-project-delete-fix-d3633d68.md create mode 100644 server/internal/httpapi/project_delete_integration_test.go diff --git a/.project-docs/30-worklog/tasks/20260813-project-delete-fix-d3633d68.md b/.project-docs/30-worklog/tasks/20260813-project-delete-fix-d3633d68.md new file mode 100644 index 0000000..c7928ca --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-project-delete-fix-d3633d68.md @@ -0,0 +1,70 @@ +# Task: Diagnose and fix project deletion + +## Identity + +- Task ID: 20260813-project-delete-fix-d3633d68 +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\PythonProjects\XQKqueue +- Base commit: 1eb36645e41dfd479799470df65d5cb8a8ac077e +- Owner: codex +- Status: Complete; ready for integration + +## Scope + +- Diagnose the missing administrator project-deletion workflow across the React admin client and Go HTTP service. +- Add a safe deletion contract for projects that have no retained operational history. +- Preserve queue sessions and their ticket, call-batch, and simulation history by rejecting deletion when retained operational data exists; preserve audit rows by detaching the project foreign key. +- Cover the administrator UI/API request and the server deletion boundary with regression tests. + +## Intent And Constraints + +- The user's confirmed symptom is that a project cannot be deleted; the repository currently exposes no project-delete UI action, web API method, HTTP route, or handler. +- PostgreSQL remains the sole write authority. The client must not infer deletion eligibility. +- Existing `ON DELETE RESTRICT` history relationships must not be replaced with broad cascading deletion. +- The working product assumption is that deletion means removing an unused/accidentally-created project; projects with retained operational history must be rejected with an explicit conflict response, while audit history must remain available without retaining a project foreign key. +- Keep the change surgical and reuse the existing admin authentication, transaction, API-error, polling-refresh, and feedback patterns. + +## Outcome + +- Confirmed the reported behavior was a missing capability rather than a failing existing request: the admin UI had no delete action, the web API client had no delete method, and the Go server had no DELETE route or handler. +- Added an administrator-only `DELETE /api/admin/projects/{id}` endpoint and registered it as an important structured-log route. +- Implemented guarded deletion in one transaction: lock the project row, reject projects with any queue session using `409 PROJECT_HAS_HISTORY`, detach retained project-scoped audits, write an unscoped `PROJECT_DELETED` snapshot audit, and delete the otherwise-unused project. +- Preserved the existing database ownership of ephemeral cascades (`user_projects` and `idempotency_keys`) and did not add broad cascades or delete queue, ticket, call-batch, device, or audit history. +- Added a two-step destructive action to the project maintenance page, including in-flight duplicate prevention, server error feedback, refresh, and replace-navigation to the project list after success. +- Added a danger-color button variant so deletion is visually distinct from ordinary project maintenance. +- Added web API/component regression coverage and full-handler PostgreSQL integration coverage for authentication, successful unused-project deletion, retained audit history, and atomic rejection of a project with queue history. + +## Verification + +- Red signal: before implementation, the new web deletion tests failed because `api.deleteProject` and the delete controls did not exist; the new Go DELETE route test returned 405 instead of reaching admin authentication. +- Focused web: `pnpm exec vitest run src/api.test.ts src/pages/AdminPage.test.tsx` passed, 2 files / 22 tests. +- The web API regression uses the server's actual empty `204 No Content` response contract rather than a JSON success fixture. +- Full web: `pnpm test:run` passed, 18 files / 69 tests. +- Web type/build: `pnpm typecheck` and `pnpm build` passed. +- Focused real PostgreSQL: `go test ./internal/httpapi -run '^TestDeleteProject(PostgresIntegration|RouteRequiresAdminAuthentication)$' -count=1 -v` passed against a fresh isolated PostgreSQL 17 database. +- Full server: `go test ./... -count=1` passed against a second fresh isolated PostgreSQL 17 database. +- The integration test exercises the complete admin login-cookie and routed-handler chain. It verifies 204 with an empty body, project/grant removal, retained old audit rows with a null project foreign key, a deletion snapshot audit, and a 409 path with no partial mutation. +- `git diff --check` passed with only existing Windows line-ending notices. +- Server static checks: `go vet ./...` and `go build ./...` passed. +- Independent read-only Sol review: PASS; no P0/P1 or unresolved P2/P3 findings. It verified PostgreSQL parent/FK lock compatibility, transactional rollback, 204 handling, audit detachment/snapshot semantics, schema-owned cascades, UI confirmation/in-flight/error/navigation behavior, logging, and task-document ownership. + +## Follow-ups + +- Promote the guarded deletion rule and admin DELETE interface into canonical business-rule/data-flow documentation through a later Integration Gate. +- If product policy later requires archived projects, deleted-code reservation, or deleted-project filtering in audit/history screens, treat that as a separate schema and cross-query design task rather than broadening this hard-delete endpoint. + +## Promotion Candidates + +- Target: `.project-docs/40-domain/business-rules.md`. + Proposal: an administrator may permanently delete only a project with no queue session; any retained queue history blocks deletion with `PROJECT_HAS_HISTORY`. Deletion of an eligible unused project preserves audit entries and records a `PROJECT_DELETED` snapshot while removing the project and ephemeral grants/idempotency state. + Evidence: `server/internal/httpapi/admin.go`, `server/internal/httpapi/project_delete_integration_test.go`, and the PostgreSQL verification recorded above. + Future impact: future project relationships must either be classified as retained deletion blockers or explicitly ephemeral; clients must not decide eligibility locally or introduce broad history cascades. + Semantic conflicts: none with current retention rules; the prior documents did not define project deletion. + Human confirmation: the user requested that projects become deletable; the guarded empty-only interpretation was selected to preserve existing history and audit contracts. +- Target: `.project-docs/20-architecture/data-flow.md`. + Proposal: add `DELETE /api/admin/projects/{id}` to the admin interface list and record that the service transaction locks the project, enforces the queue-history guard, preserves detached audits, and owns deletion. + Evidence: the registered server route, transaction handler, web client call, and full-handler integration test. + Future impact: administrator clients should surface the service's 409 reason and must not emulate or bypass deletion rules. + Semantic conflicts: none known. + Human confirmation: no additional confirmation required for documenting the implemented interface. diff --git a/server/internal/httpapi/admin.go b/server/internal/httpapi/admin.go index 068102c..1eebe6e 100644 --- a/server/internal/httpapi/admin.go +++ b/server/internal/httpapi/admin.go @@ -132,6 +132,50 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"project": projectView(project)}) } +func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { + projectID := r.PathValue("id") + if err := validateUUID(projectID); err != nil { + writeError(w, err) + return + } + + err := s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { + var project model.Project + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil { + return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在") + } + + var historyCount int64 + if err := tx.Model(&model.QueueSession{}).Where("project_id = ?", projectID).Count(&historyCount).Error; err != nil { + return err + } + if historyCount > 0 { + return &apiError{ + Status: http.StatusConflict, + Code: "PROJECT_HAS_HISTORY", + Message: "该项目已有排队运营历史,不能删除;如需停止使用,请结束该项目。", + } + } + + if err := tx.Model(&model.AuditEntry{}). + Where("project_id = ?", projectID). + Update("project_id", nil).Error; err != nil { + return err + } + actor := currentPrincipal(r.Context()).User + if err := s.addAudit(tx, r, nil, &actor.ID, "PROJECT_DELETED", "PROJECT", &projectID, + map[string]any{"project": projectView(project)}); err != nil { + return err + } + return tx.Delete(&project).Error + }) + if err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + type adminUserRequest struct { Username string `json:"username"` Password string `json:"password"` diff --git a/server/internal/httpapi/http_logging.go b/server/internal/httpapi/http_logging.go index 9958bef..f2b6c66 100644 --- a/server/internal/httpapi/http_logging.go +++ b/server/internal/httpapi/http_logging.go @@ -45,6 +45,7 @@ var ( "PUT /api/admin/users/{id}": {}, "POST /api/admin/projects": {}, "PUT /api/admin/projects/{id}": {}, + "DELETE /api/admin/projects/{id}": {}, "PUT /api/admin/projects/{id}/settings": {}, "GET /api/admin/history/tickets": {}, "GET /api/admin/history/tickets/{id}": {}, diff --git a/server/internal/httpapi/project_delete_integration_test.go b/server/internal/httpapi/project_delete_integration_test.go new file mode 100644 index 0000000..989bdce --- /dev/null +++ b/server/internal/httpapi/project_delete_integration_test.go @@ -0,0 +1,238 @@ +package httpapi + +import ( + "bytes" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "calllinesystem/server/internal/config" + "calllinesystem/server/internal/database" + "calllinesystem/server/internal/model" + "calllinesystem/server/internal/security" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +func TestDeleteProjectRouteRequiresAdminAuthentication(t *testing.T) { + server := &Server{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + now: func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) }, + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/11111111-1111-4111-8111-111111111111", nil) + + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("delete project without admin session status = %d, want 401; body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestDeleteProjectPostgresIntegration(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) + if dsn == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx := t.Context() + db, err := database.Open(ctx, dsn, logger) + if err != nil { + t.Fatal(err) + } + defer database.Close(db) + sqlDB, err := database.SQLDB(db) + if err != nil { + t.Fatal(err) + } + if err := database.Migrate(ctx, sqlDB, logger); err != nil { + t.Fatal(err) + } + + server, err := New(db, config.Config{ + EncryptionKey: bytes.Repeat([]byte{0x61}, 32), + PhoneHMACKey: bytes.Repeat([]byte{0x62}, 32), + SessionCookieName: "queue_session", + SessionTTL: time.Hour, + }, logger) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + server.now = func() time.Time { return now } + + password := "AdminPassword123!" + passwordHash, err := security.HashPassword(password) + if err != nil { + t.Fatal(err) + } + suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:10] + admin := model.User{ + ID: uuid.NewString(), Username: "delete_admin_" + suffix, PasswordHash: passwordHash, + Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&admin).Error; err != nil { + t.Fatal(err) + } + + newProject := func(code, name string) model.Project { + return model.Project{ + ID: uuid.NewString(), Code: code, Name: name, Status: model.ProjectNotOpen, + Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 1, + CallMode: model.CallModeBoth, MaxCallTicketCount: 100, + DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10, + GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 300, + ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60, VisitorNotice: model.DefaultVisitorNotice, + DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, + } + } + unused := newProject("DEL"+strings.ToUpper(suffix), "unused deletion project") + history := newProject("HIS"+strings.ToUpper(suffix), "history deletion project") + if err := db.Create(&unused).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&history).Error; err != nil { + t.Fatal(err) + } + grant := model.UserProject{UserID: admin.ID, ProjectID: unused.ID, CreatedAt: now} + if err := db.Create(&grant).Error; err != nil { + t.Fatal(err) + } + createdAudit := func(projectID string) model.AuditEntry { + return model.AuditEntry{ + ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &admin.ID, + Action: "PROJECT_CREATED", EntityType: "PROJECT", EntityID: &projectID, + Details: []byte(`{"source":"integration-test"}`), RequestID: "integration-create", + UserAgent: "integration-test", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now, + } + } + unusedCreatedAudit := createdAudit(unused.ID) + historyCreatedAudit := createdAudit(history.ID) + if err := db.Create(&unusedCreatedAudit).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&historyCreatedAudit).Error; err != nil { + t.Fatal(err) + } + queueSession := model.QueueSession{ + ID: uuid.NewString(), ProjectID: history.ID, BusinessDate: now, + Status: "ENDED", NextTicketNumber: 1, Revision: 0, OpenedAt: now, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&queueSession).Error; err != nil { + t.Fatal(err) + } + + loginRecorder := httptest.NewRecorder() + loginRequest := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", + strings.NewReader(`{"username":"`+admin.Username+`","password":"`+password+`"}`)) + server.Handler().ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("admin login status = %d, want 200; body = %s", loginRecorder.Code, loginRecorder.Body.String()) + } + var adminCookie *http.Cookie + for _, cookie := range loginRecorder.Result().Cookies() { + if cookie.Name == server.authCookieName(model.RoleAdmin) { + adminCookie = cookie + break + } + } + if adminCookie == nil { + t.Fatal("admin login did not set the admin session cookie") + } + + deleteRequest := func(projectID string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/"+projectID, nil) + request.AddCookie(adminCookie) + server.Handler().ServeHTTP(recorder, request) + return recorder + } + + t.Run("unused project is deleted while audit history is retained", func(t *testing.T) { + response := deleteRequest(unused.ID) + if response.Code != http.StatusNoContent { + t.Fatalf("delete unused project status = %d, want 204; body = %s", response.Code, response.Body.String()) + } + if response.Body.Len() != 0 { + t.Fatalf("delete unused project body = %q, want empty", response.Body.String()) + } + + if err := db.First(&model.Project{}, "id = ?", unused.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("deleted project lookup error = %v, want record not found", err) + } + var grantCount int64 + if err := db.Model(&model.UserProject{}).Where("project_id = ?", unused.ID).Count(&grantCount).Error; err != nil { + t.Fatal(err) + } + if grantCount != 0 { + t.Fatalf("project grants after deletion = %d, want 0", grantCount) + } + + var retained model.AuditEntry + if err := db.First(&retained, "id = ?", unusedCreatedAudit.ID).Error; err != nil { + t.Fatalf("load retained PROJECT_CREATED audit: %v", err) + } + if retained.ProjectID != nil { + t.Fatalf("retained PROJECT_CREATED project_id = %v, want nil", *retained.ProjectID) + } + + var deletedAudit model.AuditEntry + if err := db.Where("action = ? AND entity_type = ? AND entity_id = ?", "PROJECT_DELETED", "PROJECT", unused.ID). + First(&deletedAudit).Error; err != nil { + t.Fatalf("load PROJECT_DELETED audit: %v", err) + } + if deletedAudit.ProjectID != nil { + t.Fatalf("PROJECT_DELETED project_id = %v, want nil", *deletedAudit.ProjectID) + } + if deletedAudit.EntityID == nil || *deletedAudit.EntityID != unused.ID { + t.Fatalf("PROJECT_DELETED entity_id = %v, want %s", deletedAudit.EntityID, unused.ID) + } + if !bytes.Contains(deletedAudit.Details, []byte(unused.Code)) { + t.Fatalf("PROJECT_DELETED details = %s, want deleted project snapshot", deletedAudit.Details) + } + }) + + t.Run("project with queue history is rejected intact", func(t *testing.T) { + response := deleteRequest(history.ID) + if response.Code != http.StatusConflict { + t.Fatalf("delete history project status = %d, want 409; body = %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"code":"PROJECT_HAS_HISTORY"`) { + t.Fatalf("delete history project body = %s, want PROJECT_HAS_HISTORY", response.Body.String()) + } + if !strings.Contains(response.Body.String(), "不能删除") || !strings.Contains(response.Body.String(), "结束") { + t.Fatalf("delete history project message is not actionable Chinese: %s", response.Body.String()) + } + if err := db.First(&model.Project{}, "id = ?", history.ID).Error; err != nil { + t.Fatalf("history project was not retained: %v", err) + } + if err := db.First(&model.QueueSession{}, "id = ?", queueSession.ID).Error; err != nil { + t.Fatalf("queue session was not retained: %v", err) + } + var retained model.AuditEntry + if err := db.First(&retained, "id = ?", historyCreatedAudit.ID).Error; err != nil { + t.Fatalf("history audit was not retained: %v", err) + } + if retained.ProjectID == nil || *retained.ProjectID != history.ID { + t.Fatalf("history audit project_id = %v, want %s", retained.ProjectID, history.ID) + } + var deletedAuditCount int64 + if err := db.Model(&model.AuditEntry{}). + Where("action = ? AND entity_id = ?", "PROJECT_DELETED", history.ID). + Count(&deletedAuditCount).Error; err != nil { + t.Fatal(err) + } + if deletedAuditCount != 0 { + t.Fatalf("PROJECT_DELETED audit count for retained project = %d, want 0", deletedAuditCount) + } + }) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 60298fd..0b0d776 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -100,6 +100,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("PUT /api/admin/users/{id}", s.requireAdmin(http.HandlerFunc(s.updateAdminUser))) mux.Handle("POST /api/admin/projects", s.requireAdmin(http.HandlerFunc(s.createProject))) mux.Handle("PUT /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.updateProject))) + mux.Handle("DELETE /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.deleteProject))) mux.Handle("PUT /api/admin/projects/{id}/settings", s.requireAdmin(http.HandlerFunc(s.updateProjectSettings))) mux.Handle("GET /api/admin/history/tickets", s.requireAdmin(http.HandlerFunc(s.adminHistoryTickets))) mux.Handle("GET /api/admin/history/tickets/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryTicket))) diff --git a/web/src/api.test.ts b/web/src/api.test.ts index 042397a..9cee949 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -13,6 +13,16 @@ afterEach(() => { }); describe("mutating queue requests", () => { + it("deletes an admin project using its encoded id", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + + await api.deleteProject("project/one"); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/admin/projects/project%2Fone"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "DELETE" }); + }); + it("sends an idempotency key when creating a ticket", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ticket: { id: "ticket-1", display_number: "00001", status: "WAITING", party_size: 3 }, diff --git a/web/src/api.ts b/web/src/api.ts index fe554fb..65d7478 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -245,6 +245,9 @@ export const api = { updateProject(projectId: string, payload: import("./types").ProjectProfilePayload) { return request<{ project: ProjectDto }>(`/api/admin/projects/${encodeURIComponent(projectId)}`, { method: "PUT", body: JSON.stringify(payload) }); }, + deleteProject(projectId: string) { + return request(`/api/admin/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" }); + }, updateProjectSettings(projectId: string, payload: import("./types").UpdateProjectSettingsPayload) { return request<{ project: ProjectDto }>(`/api/admin/projects/${encodeURIComponent(projectId)}/settings`, { method: "PUT", diff --git a/web/src/pages/AdminPage.test.tsx b/web/src/pages/AdminPage.test.tsx index 8f4857f..fd8882a 100644 --- a/web/src/pages/AdminPage.test.tsx +++ b/web/src/pages/AdminPage.test.tsx @@ -1,7 +1,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import { api } from "../api"; +import { ApiError, api } from "../api"; + +const { refreshMock } = vi.hoisted(() => ({ refreshMock: vi.fn() })); vi.mock("../components/AppShell", () => ({ AppShell: ({ children }: { children: React.ReactNode }) =>
{children}
})); vi.mock("../hooks/usePollingResource", () => ({ @@ -71,12 +73,16 @@ vi.mock("../hooks/usePollingResource", () => ({ error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), - refresh: vi.fn(), + refresh: refreshMock, }), })); import { AdminPage } from "./AdminPage"; +beforeEach(() => { + refreshMock.mockReset(); +}); + describe("AdminPage active tickets", () => { it("保留核心排队数据并删除辅助概览模块", () => { render(); @@ -145,6 +151,46 @@ describe("AdminPage active tickets", () => { expect(screen.queryByRole("combobox", { name: "预计等待时间方式" })).not.toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请在入口附近等候。"); expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible(); + expect(screen.getByRole("button", { name: "删除项目" })).toBeVisible(); + }); + + it("确认后删除项目,刷新数据并返回列表", async () => { + let resolveDelete!: () => void; + const deleteProject = vi.spyOn(api, "deleteProject").mockImplementation(() => new Promise((resolve) => { resolveDelete = resolve; })); + refreshMock.mockResolvedValueOnce(undefined); + try { + render(); + + fireEvent.click(screen.getByRole("button", { name: "删除项目" })); + expect(deleteProject).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "确认删除" })); + + await waitFor(() => expect(deleteProject).toHaveBeenCalledWith("project-1")); + const deletingButton = screen.getByRole("button", { name: "正在删除" }); + expect(deletingButton).toBeDisabled(); + fireEvent.click(deletingButton); + expect(deleteProject).toHaveBeenCalledTimes(1); + resolveDelete(); + await waitFor(() => expect(refreshMock).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByRole("heading", { name: "项目管理" })).toBeVisible()); + } finally { + vi.restoreAllMocks(); + } + }); + + it("删除项目失败时留在维护页并显示服务端消息", async () => { + const deleteProject = vi.spyOn(api, "deleteProject").mockRejectedValue(new ApiError("项目存在活动排队,无法删除", 409)); + try { + render(); + + fireEvent.click(screen.getByRole("button", { name: "删除项目" })); + fireEvent.click(screen.getByRole("button", { name: "确认删除" })); + + expect(await screen.findByText("项目存在活动排队,无法删除")).toBeVisible(); + expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible(); + } finally { + vi.restoreAllMocks(); + } }); it("创建项目与维护项目使用相同字段", () => { diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 0a88298..4df43ac 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -337,10 +337,42 @@ function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) { } function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise }) { + const navigate = useNavigate(); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + if (!project) return
返回项目列表
; + + const deleteProject = async () => { + if (deleting) return; + setDeleting(true); + setDeleteError(null); + try { + await api.deleteProject(project.id); + await onRefresh(); + navigate("/admin/projects", { replace: true }); + } catch (caught) { + setDeleteError(caught instanceof ApiError ? caught.message : "项目删除失败,请稍后重试。"); + setDeleting(false); + } + }; + return

{project.name}

返回列表
+
+
+

危险操作

删除后无法撤销,请确认不再需要该项目。

+ {!confirmingDelete + ? + :
+ {" "} + +
} +
+ {deleteError ? {deleteError} : null} +
; } diff --git a/web/src/styles.css b/web/src/styles.css index 6ab1847..44ddf6b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1311,6 +1311,16 @@ h3 { background: var(--color-primary-soft); } +.button--danger { + border-color: var(--color-danger); + background: var(--color-surface); + color: var(--color-danger); +} + +.button--danger:hover:not(:disabled) { + background: var(--color-danger-soft); +} + .button--ghost { border-color: var(--color-border); background: transparent;