修复账号禁用及队列场次状态规则

问题与需求:后台禁用员工后旧会话仍可继续访问;实时队列跨天误读昨日场次;项目暂停后需禁止取号但允许叫号。

修复思路:账号权限变更时撤销会话并同步前端登录态;实时查询统一按项目时区当天场次过滤;拆分取号与叫号的状态校验,并补充前后端及 PostgreSQL 回归测试。
This commit is contained in:
2026-07-30 12:02:05 +08:00
parent 0e1ac400dc
commit 17d296263c
14 changed files with 752 additions and 54 deletions

View File

@@ -2,6 +2,8 @@ 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" }));
vi.mock("../components/AppShell", () => ({
AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<div data-testid="app-shell" data-variant={variant}>{children}</div>
@@ -11,7 +13,7 @@ vi.mock("../hooks/usePollingResource", () => ({
usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000
? {
data: { projects: [
{ id: "project-1", 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 },
{ 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,
@@ -23,7 +25,7 @@ vi.mock("../hooks/usePollingResource", () => ({
}
: {
data: {
project: { id: "project-1", 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 },
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,
waiting: Array.from({ length: 25 }, (_, index) => ({
id: `waiting-${index + 1}`,
@@ -82,7 +84,21 @@ function renderScene(path: string) {
}
describe("StaffPage scene-focused H5", () => {
beforeEach(() => sessionStorage.setItem("scenic-current-project", "project-1"));
beforeEach(() => {
staffPageTestState.projectStatus = "RUNNING";
sessionStorage.setItem("scenic-current-project", "project-1");
});
it("allows calling but blocks ticket creation while the project is paused", () => {
staffPageTestState.projectStatus = "PAUSED";
renderScene("/staff");
expect(screen.getByRole("button", { name: "快速叫下一个号" })).toBeEnabled();
expect(screen.queryByText("当前项目未开放叫号")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("link", { name: "取号" }));
expect(screen.getByRole("button", { name: "创建排队号码" })).toBeDisabled();
expect(screen.getByText("当前不可取号")).toBeInTheDocument();
});
it("keeps the call scene focused on the active batch and the primary call action", () => {
renderScene("/staff");

View File

@@ -142,7 +142,9 @@ export function StaffPage() {
const maxPartySize = selectedProject?.max_party_size ?? minPartySize;
const freshnessTime = queue?.last_success_at ?? queue?.server_time ?? queueResource.lastClientSuccessAt;
const stale = Boolean(queue) && isTimestampStale(freshnessTime, 20_000);
const writeBlocked = queueResource.offline || stale || !isProjectRunning(selectedProject?.status);
const callBlocked = queueResource.offline || stale ||
(selectedProject?.status !== "RUNNING" && selectedProject?.status !== "PAUSED");
const takeTicketBlocked = queueResource.offline || stale || !isProjectRunning(selectedProject?.status);
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 ?? [];
@@ -153,11 +155,11 @@ export function StaffPage() {
const latestCalledNumber = calledTickets[calledTickets.length - 1]?.ticket_number ?? "暂无";
const nextWaitingNumber = queue?.waiting[0]?.ticket_number ?? "暂无";
const nextWaitingPeopleCount = queue?.waiting[0]?.party_size;
const writeBlockedReason = queueResource.offline
const callBlockedReason = queueResource.offline
? "网络连接中断,暂不可叫号"
: stale
? "队列数据更新延迟,请刷新后重试"
: !isProjectRunning(selectedProject?.status)
: callBlocked
? "当前项目未开放叫号"
: waitingTicketCount === 0
? "当前没有等待号码"
@@ -447,21 +449,21 @@ export function StaffPage() {
<h2 id="staff-call-actions-title" className="staff-call-actions__title">/</h2>
<div className="staff-call-controls">
{supportsTicketCall ? <div className="staff-call-mode" aria-label="按号码叫号">
<button className="button button--primary button--call" onClick={() => void callNext("TICKET", 1)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}>
<button className="button button--primary button--call" onClick={() => void callNext("TICKET", 1)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}>
{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" || writeBlocked || waitingTicketCount === 0}></button>
<button className="button button--secondary" onClick={() => void callNext("TICKET", ticketCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}></button>
</div>
</div> : null}
{supportsPeopleCall ? <div className="staff-call-mode" aria-label="按人数叫号">
<div className="staff-batch-action">
<label className="field"><span></span><input aria-label="按人数叫号数量" type="number" min="1" max={selectedProject?.max_call_people_count ?? 100} value={peopleCallCount} onChange={(event) => setPeopleCallCount(Number(event.target.value) || 1)} /></label>
<button className="button button--secondary" onClick={() => void callNext("PEOPLE", peopleCallCount)} disabled={busyAction === "call-next" || writeBlocked || waitingTicketCount === 0}></button>
<button className="button button--secondary" onClick={() => void callNext("PEOPLE", peopleCallCount)} disabled={busyAction === "call-next" || callBlocked || waitingTicketCount === 0}></button>
</div>
</div> : null}
{writeBlockedReason ? <p className="staff-action-reason" role="status">{writeBlockedReason}</p> : null}
{callBlockedReason ? <p className="staff-action-reason" role="status">{callBlockedReason}</p> : null}
</div>
</section>
</div>
@@ -584,8 +586,8 @@ export function StaffPage() {
</div>
</fieldset>
</div>
{writeBlocked ? <span className="sr-only" id="create-ticket-state"></span> : null}
<button className="button button--primary button--wide" type="submit" aria-describedby={writeBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || writeBlocked}>
{takeTicketBlocked ? <span className="sr-only" id="create-ticket-state"></span> : null}
<button className="button button--primary button--wide" type="submit" aria-describedby={takeTicketBlocked ? "create-ticket-state" : undefined} disabled={busyAction === "create-ticket" || takeTicketBlocked}>
{busyAction === "create-ticket"
? "正在创建排队单"
: confirmDuplicatePhone