Files
XQKqueue/web/src/pages/VisitorLookupPage.tsx
2026-07-16 11:40:34 +08:00

179 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
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);
const resource = usePollingResource((signal) => api.publicStatusByPhone(submittedPhone, signal), {
enabled: Boolean(submittedPhone),
intervalMs: 3_000,
resourceKey: submittedPhone,
});
const data = resource.data;
useEffect(() => {
if (!data || !submittedPhone) return;
navigate("/visitor/phone", {
replace: true,
state: { phone: submittedPhone },
});
}, [data, navigate, submittedPhone]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextPhone = phoneInput.trim();
if (!isPhoneInputValid(nextPhone)) {
setFormError("请输入有效的手机号7-15 位数字)");
return;
}
setFormError(null);
if (nextPhone === submittedPhone) {
void resource.refresh();
return;
}
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 ? "查询延迟" : "取号与查询";
return (
<main className="visitor-page visitor-page--mobile visitor-lookup-page">
<header className="public-header visitor-public-header">
<div className="brand-lockup">
<img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
<span><strong></strong><small></small></span>
</div>
<span className={`visitor-header__meta visitor-header__meta--${liveTone}`}><i aria-hidden="true" />{liveLabel}</span>
</header>
<div className="visitor-content">
<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")}
>
</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>
);
}