合并:员工完整目标号码批量叫号修复

问题:员工端批量叫号无法直接输入 0002x 等完整目标号码。

实现:合并完整目标号码输入、FIFO 数量换算、边界校验及回归测试。
This commit is contained in:
2026-08-01 21:43:19 +08:00
2 changed files with 91 additions and 4 deletions

View File

@@ -239,6 +239,55 @@ describe("StaffPage scene-focused H5", () => {
}
});
it("can call through a full target number beyond the 0001x range", async () => {
const callNext = vi.spyOn(api, "callNext").mockResolvedValueOnce({
batch: {
id: "batch-2",
batch_number: 5,
status: "CALLED",
called_at: "2026-07-10T09:03:00Z",
ticket_count: 13,
people_count: 28,
tickets: [{ id: "called-25", ticket_number: "00025", status: "CALLED", party_size: 2 }],
},
revision: 9,
device_results: [{ channel: "SIMULATOR", status: "SUCCESS" }],
});
try {
renderScene("/staff");
const targetInput = screen.getByRole("textbox", { name: "叫到号码" });
expect(targetInput).toHaveValue("00014");
fireEvent.change(targetInput, { target: { value: "00025" } });
fireEvent.click(screen.getByRole("button", { name: "批量叫号" }));
await waitFor(() => expect(callNext).toHaveBeenCalledWith("project-1", 8, "TICKET", 13, expect.any(String)));
} finally {
callNext.mockRestore();
}
});
it("rejects a target outside the waiting queue or beyond the configured batch limit", () => {
const callNext = vi.spyOn(api, "callNext");
try {
renderScene("/staff");
const targetInput = screen.getByRole("textbox", { name: "叫到号码" });
fireEvent.change(targetInput, { target: { value: "00040" } });
fireEvent.click(screen.getByRole("button", { name: "批量叫号" }));
expect(callNext).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent("请输入当前待叫队列中的完整目标号码");
fireEvent.change(targetInput, { target: { value: "00033" } });
fireEvent.click(screen.getByRole("button", { name: "批量叫号" }));
expect(callNext).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent("从 00013 叫到 00033 共 21 个号码,超过单次上限 20 个");
} finally {
callNext.mockRestore();
}
});
it("updates number indicators after creation without showing a success card", async () => {
vi.spyOn(api, "createTicket").mockResolvedValueOnce({
ticket: { id: "ticket-30", ticket_number: "00030", status: "WAITING", party_size: 3 },

View File

@@ -90,7 +90,7 @@ export function StaffPage() {
const [formError, setFormError] = useState<string | null>(null);
const [notice, setNotice] = useState<{ scene: "call" | "tickets"; tone: "success" | "warning" | "danger"; title: string; detail?: string; url?: string } | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [ticketCallCount, setTicketCallCount] = useState(1);
const [ticketCallTarget, setTicketCallTarget] = useState("");
const [peopleCallCount, setPeopleCallCount] = useState(1);
const [visibleWaitingTicketCount, setVisibleWaitingTicketCount] = useState(WAITING_TICKET_PAGE_SIZE);
const [confirmDuplicatePhone, setConfirmDuplicatePhone] = useState(false);
@@ -148,6 +148,8 @@ export function StaffPage() {
const waitingTicketCount = Math.max(0, Number(queue?.metrics.waiting_ticket_count ?? queue?.metrics.waiting_count) || 0);
const waitingPeopleCount = Math.max(0, Number(queue?.metrics.waiting_people_count) || 0);
const waitingTickets = queue?.waiting ?? [];
const defaultTicketCallCount = selectedProject?.default_call_ticket_count ?? selectedProject?.call_batch_size ?? selectedProject?.batch_size ?? 1;
const defaultTicketCallTarget = waitingTickets[Math.min(Math.max(defaultTicketCallCount, 1), waitingTickets.length) - 1]?.ticket_number ?? "";
const visibleWaitingTickets = waitingTickets.slice(0, visibleWaitingTicketCount);
const canShowMoreWaitingTickets = visibleWaitingTickets.length < waitingTickets.length;
const canCollapseWaitingTickets = visibleWaitingTickets.length > WAITING_TICKET_PAGE_SIZE;
@@ -233,7 +235,6 @@ export function StaffPage() {
useEffect(() => {
if (!selectedProject) return;
setTicketCallCount(selectedProject.default_call_ticket_count ?? selectedProject.call_batch_size ?? selectedProject.batch_size ?? 1);
setPeopleCallCount(selectedProject.default_call_people_count ?? 1);
const minPartySize = selectedProject.min_party_size ?? 1;
const maxPartySize = selectedProject.max_party_size ?? minPartySize;
@@ -245,6 +246,14 @@ export function StaffPage() {
}));
}, [selectedProject?.id]);
useEffect(() => {
setTicketCallTarget((current) => (
waitingTickets.some((ticket) => ticket.ticket_number === current)
? current
: defaultTicketCallTarget
));
}, [defaultTicketCallTarget, queue?.revision, selectedProjectId]);
useGSAP(() => {
if (!showProjectSwitcher) return;
const mm = gsap.matchMedia();
@@ -278,6 +287,7 @@ export function StaffPage() {
setFormError(null);
setConfirmDuplicatePhone(false);
setPhoneError(null);
setTicketCallTarget("");
createIntentRef.current = null;
callIntentRef.current = null;
};
@@ -383,6 +393,34 @@ export function StaffPage() {
}
};
const callThroughTicket = () => {
const target = ticketCallTarget.trim();
const targetIndex = waitingTickets.findIndex((ticket) => ticket.ticket_number === target);
if (targetIndex < 0) {
setNotice({
scene: "call",
tone: "danger",
title: "叫号未提交",
detail: "请输入当前待叫队列中的完整目标号码",
});
return;
}
const count = targetIndex + 1;
const limit = selectedProject?.max_call_ticket_count ?? 100;
if (count > limit) {
setNotice({
scene: "call",
tone: "danger",
title: "叫号未提交",
detail: `${waitingTickets[0].ticket_number} 叫到 ${target}${count} 个号码,超过单次上限 ${limit}`,
});
return;
}
void callNext("TICKET", count);
};
const noticeBanner = notice && notice.scene === scene ? (
<div className={notice.scene === "call" ? "staff-call-notice" : undefined}>
<FeedbackBanner
@@ -453,8 +491,8 @@ export function StaffPage() {
{busyAction === "call-next" ? "正在叫号" : "快速叫下一个号"}
</button>
<div className="staff-batch-action">
<label className="field"><span></span><input aria-label="按号码叫号数量" type="number" min="1" max={selectedProject?.max_call_ticket_count ?? 100} value={ticketCallCount} onChange={(event) => setTicketCallCount(Number(event.target.value) || 1)} /></label>
<button className="button button--secondary" onClick={() => void callNext("TICKET", ticketCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}></button>
<label className="field"><span></span><input aria-label="叫到号码" type="text" inputMode="numeric" autoComplete="off" value={ticketCallTarget} onChange={(event) => setTicketCallTarget(event.target.value.replace(/\D/g, ""))} /></label>
<button className="button button--secondary" onClick={callThroughTicket} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}></button>
</div>
</div> : null}
{supportsPeopleCall ? <div className="staff-call-mode" aria-label="按人数叫号">