feat: add party-size queueing and call modes

This commit is contained in:
wangxuming
2026-07-16 11:40:34 +08:00
parent 7f751bebae
commit 66951b4dc3
53 changed files with 3348 additions and 533 deletions

View File

@@ -1,17 +1,27 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import type { FormEvent, KeyboardEvent } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api";
import { FeedbackBanner, LoadingState } from "../components/Feedback";
import { usePollingResource } from "../hooks/usePollingResource";
import { VisitorTakeTicketForm } from "./VisitorTakeTicketPage";
function isPhoneInputValid(value: string): boolean {
const digits = value.replace(/\D/g, "");
return /^[+\d\s\-()]+$/.test(value) && digits.length >= 7 && digits.length <= 15;
}
type VisitorServiceTab = "take-ticket" | "lookup";
interface VisitorLookupLocationState {
activeTab?: VisitorServiceTab;
}
export function VisitorLookupPage() {
const location = useLocation();
const navigate = useNavigate();
const requestedTab = (location.state as VisitorLookupLocationState | null)?.activeTab;
const [activeTab, setActiveTab] = useState<VisitorServiceTab>(requestedTab === "lookup" ? "lookup" : "take-ticket");
const [phoneInput, setPhoneInput] = useState("");
const [submittedPhone, setSubmittedPhone] = useState("");
const [formError, setFormError] = useState<string | null>(null);
@@ -45,8 +55,23 @@ export function VisitorLookupPage() {
setSubmittedPhone(nextPhone);
}
function handleTabKeyDown(event: KeyboardEvent<HTMLButtonElement>, currentTab: VisitorServiceTab) {
let nextTab: VisitorServiceTab | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
nextTab = currentTab === "take-ticket" ? "lookup" : "take-ticket";
} else if (event.key === "Home") {
nextTab = "take-ticket";
} else if (event.key === "End") {
nextTab = "lookup";
}
if (!nextTab) return;
event.preventDefault();
setActiveTab(nextTab);
document.getElementById(nextTab === "take-ticket" ? "visitor-take-ticket-tab" : "visitor-lookup-tab")?.focus();
}
const liveTone = resource.offline ? "offline" : resource.error ? "stale" : "live";
const liveLabel = resource.offline ? "连接中断" : resource.error ? "查询延迟" : "手机号查询";
const liveLabel = resource.offline ? "连接中断" : resource.error ? "查询延迟" : "取号与查询";
return (
<main className="visitor-page visitor-page--mobile visitor-lookup-page">
@@ -58,49 +83,95 @@ export function VisitorLookupPage() {
<span className={`visitor-header__meta visitor-header__meta--${liveTone}`}><i aria-hidden="true" />{liveLabel}</span>
</header>
<div className="visitor-content">
<section className="visitor-lookup-card" aria-labelledby="visitor-lookup-title">
<div className="visitor-lookup-card__intro">
<span></span>
<h1 id="visitor-lookup-title"></h1>
<p></p>
</div>
<form className="visitor-lookup-form" onSubmit={submit} noValidate>
<label className="field" htmlFor="visitor-phone">
<span></span>
<input
id="visitor-phone"
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="请输入手机号"
value={phoneInput}
onChange={(event) => {
setPhoneInput(event.target.value);
if (formError) setFormError(null);
}}
aria-invalid={formError ? "true" : "false"}
aria-describedby={formError ? "visitor-phone-error" : "visitor-phone-help"}
/>
{formError ? <small id="visitor-phone-error" className="field-error" role="alert">{formError}</small> : null}
</label>
<button className="button button--primary button--wide" type="submit" disabled={resource.loading}>
{resource.loading ? "正在查询…" : "查询排队状态"}
</button>
</form>
<p id="visitor-phone-help" className="visitor-lookup-card__help"></p>
</section>
{resource.loading && !data ? <LoadingState label="正在查询您的排队号码" /> : null}
{resource.error && !data ? (
<FeedbackBanner
tone="danger"
title="暂时无法查询排队状态"
action={<button className="button button--secondary button--small" onClick={resource.refresh}></button>}
<div className="visitor-service-tabs" role="tablist" aria-label="取号与查号">
<button
id="visitor-take-ticket-tab"
className={activeTab === "take-ticket" ? "active" : undefined}
type="button"
role="tab"
aria-selected={activeTab === "take-ticket"}
aria-controls="visitor-take-ticket-panel"
tabIndex={activeTab === "take-ticket" ? 0 : -1}
onClick={() => setActiveTab("take-ticket")}
onKeyDown={(event) => handleTabKeyDown(event, "take-ticket")}
>
{resource.error.status === 429 ? "查询次数过多,请稍后再试。" : resource.error.message}
</FeedbackBanner>
) : null}
</button>
<button
id="visitor-lookup-tab"
className={activeTab === "lookup" ? "active" : undefined}
type="button"
role="tab"
aria-selected={activeTab === "lookup"}
aria-controls="visitor-lookup-panel"
tabIndex={activeTab === "lookup" ? 0 : -1}
onClick={() => setActiveTab("lookup")}
onKeyDown={(event) => handleTabKeyDown(event, "lookup")}
>
</button>
</div>
<div
id="visitor-take-ticket-panel"
className="visitor-service-panel"
role="tabpanel"
aria-labelledby="visitor-take-ticket-tab"
hidden={activeTab !== "take-ticket"}
>
<VisitorTakeTicketForm />
</div>
<div
id="visitor-lookup-panel"
className="visitor-service-panel"
role="tabpanel"
aria-labelledby="visitor-lookup-tab"
hidden={activeTab !== "lookup"}
>
<section className="visitor-lookup-card" aria-labelledby="visitor-lookup-title">
<div className="visitor-lookup-card__intro">
<span></span>
<h1 id="visitor-lookup-title"></h1>
<p></p>
</div>
<form className="visitor-lookup-form" onSubmit={submit} noValidate>
<label className="field" htmlFor="visitor-phone">
<span></span>
<input
id="visitor-phone"
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="请输入手机号"
value={phoneInput}
onChange={(event) => {
setPhoneInput(event.target.value);
if (formError) setFormError(null);
}}
aria-invalid={formError ? "true" : "false"}
aria-describedby={formError ? "visitor-phone-error" : undefined}
/>
{formError ? <small id="visitor-phone-error" className="field-error" role="alert">{formError}</small> : null}
</label>
<button className="button button--primary button--wide" type="submit" disabled={resource.loading}>
{resource.loading ? "正在查询…" : "查询排队状态"}
</button>
</form>
</section>
{resource.loading && !data ? <LoadingState label="正在查询您的排队号码" /> : null}
{resource.error && !data ? (
<FeedbackBanner
tone="danger"
title="暂时无法查询排队状态"
action={<button className="button button--secondary button--small" onClick={resource.refresh}></button>}
>
{resource.error.status === 429 ? "查询次数过多,请稍后再试。" : resource.error.message}
</FeedbackBanner>
) : null}
</div>
</div>
</main>
);