Files
XQKqueue/web/src/pages/VisitorPhonePage.tsx
2026-07-15 11:26:37 +08:00

169 lines
7.0 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 { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api";
import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { usePollingResource } from "../hooks/usePollingResource";
import { isTimestampStale, visitorStatusMeta } from "../lib/format";
import type { PublicStatusDto } from "../types";
import { VisitorTicketCard } from "./VisitorTicketCard";
const PHONE_STORAGE_KEY = "scenic-visitor-query-phone";
function ticketKey(ticket: PublicStatusDto): string {
return `${ticket.project?.id ?? ticket.project_name}:${ticket.ticket_number}`;
}
function readStoredPhone(): string {
try {
return window.sessionStorage.getItem(PHONE_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
function storePhone(phone: string): void {
try {
window.sessionStorage.setItem(PHONE_STORAGE_KEY, phone);
} catch {
// Session storage is only a refresh convenience for this temporary flow.
}
}
function clearStoredPhone(): void {
try {
window.sessionStorage.removeItem(PHONE_STORAGE_KEY);
} catch {
// Ignore storage failures; the page can still navigate normally.
}
}
interface VisitorPhoneLocationState {
phone?: string;
}
export function VisitorPhonePage() {
const location = useLocation();
const navigate = useNavigate();
const locationPhone = (location.state as VisitorPhoneLocationState | null)?.phone?.trim() ?? "";
const [phone] = useState(() => locationPhone || readStoredPhone());
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const resource = usePollingResource((signal) => api.publicStatusByPhone(phone, signal), {
enabled: Boolean(phone),
intervalMs: 3_000,
resourceKey: phone || "visitor-phone",
});
const data = resource.data;
const tickets = data?.tickets ?? [];
const selectedTicket = tickets.find((ticket) => ticketKey(ticket) === selectedKey) ?? tickets[0];
const freshnessTime = resource.lastClientSuccessAt;
const stale = Boolean(data) && isTimestampStale(freshnessTime, 30_000);
useEffect(() => {
if (locationPhone) storePhone(locationPhone);
}, [locationPhone]);
useEffect(() => {
if (!tickets.length) {
setSelectedKey(null);
return;
}
const currentKey = selectedKey && tickets.some((ticket) => ticketKey(ticket) === selectedKey)
? selectedKey
: ticketKey(tickets[0]);
if (currentKey !== selectedKey) setSelectedKey(currentKey);
}, [selectedKey, tickets]);
function returnToLookup() {
clearStoredPhone();
navigate("/visitor");
}
const liveTone = resource.offline ? "offline" : stale || resource.error ? "stale" : "live";
const liveLabel = resource.offline ? "连接中断" : stale || resource.error ? "更新延迟" : "实时更新";
return (
<main className="visitor-page visitor-page--mobile visitor-phone-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">
{!phone ? (
<FeedbackBanner tone="warning" title="查询会话已失效" action={<button className="button button--secondary button--small" onClick={returnToLookup}></button>}>
</FeedbackBanner>
) : null}
{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}
{data ? (
<>
<FreshnessBanner
offline={resource.offline}
stale={stale}
timestamp={freshnessTime}
refreshing={resource.refreshing}
errorMessage={resource.error?.message}
onRetry={resource.refresh}
/>
{tickets.length === 0 ? (
<>
<EmptyState title="暂未找到活动排队号码" description="请核对手机号,或联系现场工作人员。" />
<button className="button button--secondary visitor-refresh" onClick={returnToLookup}></button>
</>
) : (
<>
{tickets.length > 1 ? (
<section className="visitor-lookup-results" aria-labelledby="visitor-lookup-results-title">
<div className="visitor-lookup-results__heading">
<div>
<span></span>
<h2 id="visitor-lookup-results-title"> {tickets.length} </h2>
</div>
<small></small>
</div>
<div className="visitor-lookup-options" role="list">
{tickets.map((ticket) => {
const key = ticketKey(ticket);
const statusMeta = visitorStatusMeta(ticket.status, ticket.people_ahead);
return (
<div key={key} role="listitem">
<button
className="visitor-lookup-option"
type="button"
aria-pressed={selectedTicket ? key === ticketKey(selectedTicket) : false}
onClick={() => setSelectedKey(key)}
>
<span>{ticket.project_name}</span>
<strong>{ticket.ticket_number}</strong>
<small>{statusMeta.label}</small>
</button>
</div>
);
})}
</div>
</section>
) : null}
{selectedTicket ? <VisitorTicketCard data={selectedTicket} lastUpdatedAt={freshnessTime} /> : null}
<button className="button button--secondary visitor-refresh" onClick={resource.refresh}></button>
<button className="button button--ghost visitor-refresh" onClick={returnToLookup}></button>
<p className="visitor-trust"></p>
</>
)}
</>
) : null}
</div>
</main>
);
}