修复项目无法删除问题

This commit is contained in:
2026-08-13 20:42:09 +08:00
parent cb88a69012
commit a407e69ba7
18 changed files with 623 additions and 63 deletions

View File

@@ -151,22 +151,23 @@ 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();
expect(screen.getByRole("button", { name: "除项目" })).toBeVisible();
expect(screen.getByText("有运营历史的已结束项目将从所有项目列表隐藏,历史记录仍保留;无运营历史项目会永久删除。")).toBeVisible();
});
it("确认后除项目,刷新数据并返回列表", async () => {
it("确认后归档或移除项目,刷新数据并返回列表", async () => {
let resolveDelete!: () => void;
const deleteProject = vi.spyOn(api, "deleteProject").mockImplementation(() => new Promise<void>((resolve) => { resolveDelete = resolve; }));
refreshMock.mockResolvedValueOnce(undefined);
try {
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
fireEvent.click(screen.getByRole("button", { name: "除项目" }));
fireEvent.click(screen.getByRole("button", { name: "除项目" }));
expect(deleteProject).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "确认除" }));
fireEvent.click(screen.getByRole("button", { name: "确认除" }));
await waitFor(() => expect(deleteProject).toHaveBeenCalledWith("project-1"));
const deletingButton = screen.getByRole("button", { name: "正在除" });
const deletingButton = screen.getByRole("button", { name: "正在除" });
expect(deletingButton).toBeDisabled();
fireEvent.click(deletingButton);
expect(deleteProject).toHaveBeenCalledTimes(1);
@@ -178,15 +179,16 @@ describe("AdminPage active tickets", () => {
}
});
it("除项目失败时留在维护页并显示服务端消息", async () => {
it("归档或移除项目失败时留在维护页并显示服务端消息", async () => {
const deleteProject = vi.spyOn(api, "deleteProject").mockRejectedValue(new ApiError("项目存在活动排队,无法删除", 409));
try {
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
fireEvent.click(screen.getByRole("button", { name: "除项目" }));
fireEvent.click(screen.getByRole("button", { name: "确认除" }));
fireEvent.click(screen.getByRole("button", { name: "除项目" }));
fireEvent.click(screen.getByRole("button", { name: "确认除" }));
expect(await screen.findByText("项目存在活动排队,无法删除")).toBeVisible();
expect(screen.getByText("移除失败")).toBeVisible();
expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible();
} finally {
vi.restoreAllMocks();

View File

@@ -353,7 +353,7 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
await onRefresh();
navigate("/admin/projects", { replace: true });
} catch (caught) {
setDeleteError(caught instanceof ApiError ? caught.message : "项目除失败,请稍后重试。");
setDeleteError(caught instanceof ApiError ? caught.message : "项目除失败,请稍后重试。");
setDeleting(false);
}
};
@@ -363,15 +363,15 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
<ProjectForm project={project} onSaved={onRefresh} />
<section className="panel" aria-label="危险操作">
<div className="panel__header">
<div><h3></h3><p></p></div>
<div><h3></h3><p></p></div>
{!confirmingDelete
? <button className="button button--danger" type="button" onClick={() => { setConfirmingDelete(true); setDeleteError(null); }}></button>
? <button className="button button--danger" type="button" onClick={() => { setConfirmingDelete(true); setDeleteError(null); }}></button>
: <div>
<button className="button button--secondary" type="button" onClick={() => setConfirmingDelete(false)} disabled={deleting}></button>{" "}
<button className="button button--danger" type="button" onClick={deleteProject} disabled={deleting}>{deleting ? "正在除" : "确认除"}</button>
<button className="button button--danger" type="button" onClick={deleteProject} disabled={deleting}>{deleting ? "正在除" : "确认除"}</button>
</div>}
</div>
{deleteError ? <FeedbackBanner tone="danger" title="除失败">{deleteError}</FeedbackBanner> : null}
{deleteError ? <FeedbackBanner tone="danger" title="除失败">{deleteError}</FeedbackBanner> : null}
</section>
</div>;
}

View File

@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING" }));
const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING", projectsVisible: true, exerciseQueueLoad: false }));
vi.mock("../components/AppShell", () => ({
AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
@@ -10,12 +10,12 @@ vi.mock("../components/AppShell", () => ({
),
}));
vi.mock("../hooks/usePollingResource", () => ({
usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000
usePollingResource: (_load: unknown, options: { intervalMs: number; enabled?: boolean }) => options.intervalMs === 60_000
? {
data: { projects: [
data: { projects: staffPageTestState.projectsVisible ? [
{ id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
{ id: "project-2", name: "西门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
] },
] : [] },
loading: false,
refreshing: false,
error: null,
@@ -23,7 +23,7 @@ vi.mock("../hooks/usePollingResource", () => ({
lastClientSuccessAt: new Date().toISOString(),
refresh: vi.fn(),
}
: {
: (staffPageTestState.exerciseQueueLoad && options.enabled !== false && (_load as (signal: AbortSignal) => unknown)(new AbortController().signal), {
data: {
project: { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 },
revision: 8,
@@ -70,7 +70,7 @@ vi.mock("../hooks/usePollingResource", () => ({
offline: false,
lastClientSuccessAt: new Date().toISOString(),
refresh: vi.fn(),
},
}),
}));
vi.mock("../auth/AuthContext", () => ({
useAuth: () => ({ user: { id: "staff-1", username: "staff01", display_name: "小李", role: "STAFF" } }),
@@ -86,9 +86,28 @@ function renderScene(path: string) {
describe("StaffPage scene-focused H5", () => {
beforeEach(() => {
staffPageTestState.projectStatus = "RUNNING";
staffPageTestState.projectsVisible = true;
staffPageTestState.exerciseQueueLoad = false;
sessionStorage.setItem("scenic-current-project", "project-1");
});
it("clears a removed project's cached selection and queue after a successful empty project response", async () => {
staffPageTestState.exerciseQueueLoad = true;
const queue = vi.spyOn(api, "queue").mockResolvedValue({} as never);
const view = renderScene("/staff");
expect(queue).toHaveBeenCalledWith("project-1", expect.any(AbortSignal));
expect(screen.getByText("00012")).toBeVisible();
queue.mockClear();
staffPageTestState.projectsVisible = false;
view.rerender(<MemoryRouter initialEntries={["/staff"]}><StaffPage /></MemoryRouter>);
await waitFor(() => expect(sessionStorage.getItem("scenic-current-project")).toBeNull());
expect(queue).not.toHaveBeenCalled();
expect(screen.queryByText("东门观光车")).not.toBeInTheDocument();
expect(screen.queryByText("00012")).not.toBeInTheDocument();
});
it("allows calling but blocks ticket creation while the project is paused", () => {
staffPageTestState.projectStatus = "PAUSED";
renderScene("/staff");

View File

@@ -106,14 +106,23 @@ export function StaffPage() {
const callIntentRef = useRef<WriteIntent | null>(null);
const projects = projectResource.data?.projects ?? [];
const hasSuccessfulEmptyProjectList = Boolean(projectResource.data && !projectResource.loading && !projectResource.error && projects.length === 0);
const activeProjectId = hasSuccessfulEmptyProjectList ? "" : selectedProjectId;
useEffect(() => {
if (hasSuccessfulEmptyProjectList) {
setSelectedProjectId("");
setPendingProjectId("");
setShowProjectSwitcher(false);
sessionStorage.removeItem("scenic-current-project");
return;
}
if (!projects.length) return;
const available = projects.some((project) => project.id === selectedProjectId);
if (!available) {
setSelectedProjectId(projects[0].id);
setPendingProjectId(projects[0].id);
}
}, [projects, selectedProjectId]);
}, [hasSuccessfulEmptyProjectList, projects, selectedProjectId]);
useEffect(() => {
if (selectedProjectId) sessionStorage.setItem("scenic-current-project", selectedProjectId);
@@ -130,10 +139,10 @@ export function StaffPage() {
}, [notice]);
const queueResource = usePollingResource(
(signal) => api.queue(selectedProjectId, signal),
{ enabled: Boolean(selectedProjectId), intervalMs: 5_000, resourceKey: selectedProjectId },
(signal) => api.queue(activeProjectId, signal),
{ enabled: Boolean(activeProjectId), intervalMs: 5_000, resourceKey: activeProjectId },
);
const queue = queueResource.data;
const queue = activeProjectId ? queueResource.data : undefined;
const selectedProject = queue?.project ?? projects.find((project) => project.id === selectedProjectId);
const callMode = selectedProject?.call_mode ?? "TICKET";
const supportsTicketCall = callMode === "TICKET" || callMode === "BOTH";