diff --git a/README.md b/README.md index 35db7f1..97f2f4f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ docker compose up -d postgres - `/staff`:员工 H5 叫号页,聚焦当前批次与叫下一批。 - `/staff/tickets`:员工 H5 取号页与等待队列核对。 - 员工端每次“叫下一批”会自动结束上一批,再按 FIFO 叫出固定数量的连续号码。 +- `/visitor`:游客运营测试手机号查询入口;查询成功后跳转到号码状态页;正式上线前需替换为验证码或外部身份接口。 +- `/visitor/phone`:手机号查询后的游客号码状态页,不再显示手机号输入框。 - `/visitor/:token`:游客私密状态页。 - `/display/:token`:单项目只读公示屏。 - `/admin`:管理端运营概览。 diff --git a/progress.md b/progress.md index 8626664..d655d52 100644 --- a/progress.md +++ b/progress.md @@ -465,3 +465,17 @@ - 补充创建页移动端操作区换行规则,避免取消/创建双按钮在窄屏下挤压。 - 验证通过:前端 TypeScript、11 个文件 38 项 Vitest、Vite 生产构建、Go 全量测试、`go vet`、缺失系统时区文件场景回归测试和 `git diff --check`。 - 本地浏览器连接验证时无可用管理端登录会话,未代填账号密码;未登录页面正确显示管理端登录入口,页面业务布局由组件测试和构建验证覆盖。 + +# Session: 2026-07-15(游客手机号运营测试查询) + +- 新增 `POST /api/public/status/search`,使用现有手机号 HMAC 索引匹配所有项目当前 `WAITING/CALLED/ARRIVED` 活动号码;生产环境返回 404,后续替换为验证码或外部身份接口。 +- 新增 `/visitor` 页面:手机号输入、无结果/错误/限流提示、同手机号多号码按项目/票号选择、复用游客状态卡片和自动刷新;现有 `/visitor/:token` 保持兼容。 +- 增加临时内存查询限流、API/页面测试、真实烟测中的单号与重复活动号码查询,以及 README/服务端接口说明。 +- 验证通过:Go 全量测试、`go vet`、前端 41 项 Vitest、TypeScript 检查、Vite 生产构建、`make test-db`、`make smoke-real`、`git diff --check`。 + +# Session: 2026-07-15(手机号查询后跳转号码页) + +- `/visitor` 现在只负责提交手机号;查询成功后 replace 导航到 `/visitor/phone`。 +- 新增无手机号输入框的号码状态页,复用游客状态卡片和自动刷新;同手机号多个活动号码仍可在号码页选择。 +- 增加“查询其他手机号”返回入口,保持旧的 `/visitor/:token` 私密状态页兼容。 +- 验证通过:前端 13 个测试文件共 43 项测试、TypeScript 检查、Vite 生产构建和 `git diff --check`。 diff --git a/scripts/smoke.py b/scripts/smoke.py index f0c94e8..c95cd98 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -219,6 +219,14 @@ def main() -> None: assert public["ticket_number"] == first_ticket["ticket_number"] assert_public_phone_projection(public, phone, "public status") + phone_lookup = client.request( + "POST", "/api/public/status/search", {"phone": phone} + ) + assert len(phone_lookup.get("tickets", [])) == 1 + phone_lookup_ticket = phone_lookup["tickets"][0] + assert phone_lookup_ticket["ticket_number"] == first_ticket["ticket_number"] + assert_public_phone_projection(phone_lookup_ticket, phone, "phone lookup") + client.request( "POST", "/api/admin/auth/login", @@ -266,6 +274,18 @@ def main() -> None: source="confirmed duplicate create response", ) + phone_lookup = client.request( + "POST", "/api/public/status/search", {"phone": phone} + ) + phone_lookup_numbers = { + ticket["ticket_number"] for ticket in phone_lookup.get("tickets", []) + } + assert phone_lookup_numbers == { + first_ticket["ticket_number"], confirmed_ticket["ticket_number"] + } + for ticket in phone_lookup["tickets"]: + assert_public_phone_projection(ticket, phone, "phone lookup with duplicate tickets") + snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue") project = snapshot["project"] batch_size = int( diff --git a/server/README.md b/server/README.md index f4b0cf7..a96c187 100644 --- a/server/README.md +++ b/server/README.md @@ -35,6 +35,11 @@ If a phone already has active tickets, ticket creation returns `DUPLICATE_PHONE`; repeat with the same request body except `allow_duplicate: true` and a new idempotency key after the employee confirms. +`POST /api/public/status/search` is a temporary non-production operational-test +endpoint. It accepts `{ "phone": "..." }` and returns all current active +tickets associated with that phone. It is disabled when `APP_ENV=production`; +replace it with OTP or an external identity interface before formal launch. + ## Tests ```sh diff --git a/server/internal/httpapi/limiter.go b/server/internal/httpapi/limiter.go index e844ab0..46f70c4 100644 --- a/server/internal/httpapi/limiter.go +++ b/server/internal/httpapi/limiter.go @@ -17,6 +17,52 @@ type loginLimiter struct { now func() time.Time } +type queryLimitEntry struct { + windowStart time.Time + count int +} + +// queryLimiter is deliberately small and in-memory for the temporary public +// phone lookup. The production replacement will be an OTP or external +// identity provider, so this limiter is only a safety net for the test flow. +type queryLimiter struct { + mu sync.Mutex + entries map[string]queryLimitEntry + now func() time.Time + limit int + window time.Duration +} + +func newQueryLimiter(now func() time.Time, limit int, window time.Duration) *queryLimiter { + return &queryLimiter{ + entries: make(map[string]queryLimitEntry), now: now, limit: limit, window: window, + } +} + +func (l *queryLimiter) allow(key string) (bool, time.Duration) { + if l == nil || l.limit <= 0 || l.window <= 0 { + return true, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + + now := l.now() + entry, ok := l.entries[key] + if !ok || now.Sub(entry.windowStart) >= l.window { + entry = queryLimitEntry{windowStart: now} + } + if entry.count >= l.limit { + retry := l.window - now.Sub(entry.windowStart) + if retry < 0 { + retry = 0 + } + return false, retry + } + entry.count++ + l.entries[key] = entry + return true, 0 +} + func newLoginLimiter(now func() time.Time) *loginLimiter { return &loginLimiter{attempts: make(map[string]loginAttempt), now: now} } diff --git a/server/internal/httpapi/limiter_test.go b/server/internal/httpapi/limiter_test.go new file mode 100644 index 0000000..c26861f --- /dev/null +++ b/server/internal/httpapi/limiter_test.go @@ -0,0 +1,26 @@ +package httpapi + +import ( + "testing" + "time" +) + +func TestQueryLimiterResetsAfterWindow(t *testing.T) { + now := time.Unix(100, 0) + limiter := newQueryLimiter(func() time.Time { return now }, 2, time.Minute) + + if allowed, _ := limiter.allow("phone:test"); !allowed { + t.Fatal("first query should be allowed") + } + if allowed, _ := limiter.allow("phone:test"); !allowed { + t.Fatal("second query should be allowed") + } + if allowed, retry := limiter.allow("phone:test"); allowed || retry <= 0 { + t.Fatalf("third query should be limited with a retry duration, got allowed=%v retry=%s", allowed, retry) + } + + now = now.Add(time.Minute) + if allowed, _ := limiter.allow("phone:test"); !allowed { + t.Fatal("query should be allowed after the window resets") + } +} diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 55305e2..bb82d49 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -27,29 +27,113 @@ func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) { writeError(w, mapNotFound(err, "STATUS_NOT_FOUND", "排队状态不存在或链接已失效")) return } - var project model.Project - var session model.QueueSession - if err := s.db.WithContext(r.Context()).First(&project, "id = ?", ticket.ProjectID).Error; err != nil { - writeError(w, err) - return - } - if err := s.db.WithContext(r.Context()).First(&session, "id = ? AND project_id = ?", ticket.QueueSessionID, ticket.ProjectID).Error; err != nil { - writeError(w, err) - return - } - phoneSuffix, err := s.ticketPhoneLast4(ticket) + view, err := s.publicStatusView(r.Context(), ticket) if err != nil { writeError(w, err) return } + writeJSON(w, http.StatusOK, view) +} + +type publicPhoneQueryRequest struct { + Phone string `json:"phone"` +} + +// publicStatusByPhone is a temporary operational-test flow. It must be +// replaced by OTP or an external identity provider before production use. +func (s *Server) publicStatusByPhone(w http.ResponseWriter, r *http.Request) { + if s.config.Environment == "production" { + writeError(w, &apiError{Status: http.StatusNotFound, Code: "STATUS_NOT_FOUND", Message: "排队状态不存在或链接已失效"}) + return + } + if s.publicQueryLimiter != nil { + if allowed, retry := s.publicQueryLimiter.allow("ip:" + publicQueryClientKey(r)); !allowed { + writePublicQueryRateLimit(w, retry) + return + } + } + + var input publicPhoneQueryRequest + if err := decodeJSON(r, &input); err != nil { + writeError(w, err) + return + } + phone, err := security.NormalizePhone(input.Phone) + if err != nil { + writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_PHONE", Message: "请输入有效的手机号"}) + return + } + phoneDigest := s.cipher.Digest(phone) + if s.publicQueryLimiter != nil { + if allowed, retry := s.publicQueryLimiter.allow("phone:" + phoneDigest); !allowed { + writePublicQueryRateLimit(w, retry) + return + } + } + + var tickets []model.QueueTicket + if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). + Joins("JOIN projects ON projects.id = queue_tickets.project_id"). + Joins("JOIN queue_sessions ON queue_sessions.id = queue_tickets.queue_session_id AND queue_sessions.project_id = queue_tickets.project_id"). + Where("queue_tickets.phone_hmac = ? AND queue_tickets.status IN ? AND queue_sessions.status IN ? AND projects.status IN ?", + phoneDigest, + []string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}, + []string{"RUNNING", "PAUSED"}, + []string{model.ProjectRunning, model.ProjectPaused}). + Order("projects.name ASC, queue_tickets.ticket_number ASC"). + Find(&tickets).Error; err != nil { + writeError(w, err) + return + } + + views := make([]map[string]any, 0, len(tickets)) + for _, ticket := range tickets { + view, err := s.publicStatusView(r.Context(), ticket) + if err != nil { + writeError(w, err) + return + } + views = append(views, view) + } + writeJSON(w, http.StatusOK, map[string]any{"tickets": views}) +} + +func publicQueryClientKey(r *http.Request) string { + if ip := remoteIP(r); ip != nil { + return *ip + } + return "unknown" +} + +func writePublicQueryRateLimit(w http.ResponseWriter, retry time.Duration) { + seconds := int((retry + time.Second - 1) / time.Second) + if seconds < 1 { + seconds = 1 + } + w.Header().Set("Retry-After", fmt.Sprintf("%d", seconds)) + writeError(w, &apiError{Status: http.StatusTooManyRequests, Code: "PUBLIC_QUERY_RATE_LIMITED", Message: "查询次数过多,请稍后再试"}) +} + +func (s *Server) publicStatusView(ctx context.Context, ticket model.QueueTicket) (map[string]any, error) { + var project model.Project + var session model.QueueSession + if err := s.db.WithContext(ctx).First(&project, "id = ?", ticket.ProjectID).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).First(&session, "id = ? AND project_id = ?", ticket.QueueSessionID, ticket.ProjectID).Error; err != nil { + return nil, err + } + phoneSuffix, err := s.ticketPhoneLast4(ticket) + if err != nil { + return nil, err + } peopleAhead := 0 if ticket.Status == model.TicketWaiting { var count int64 - if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). + if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). Where("project_id = ? AND queue_session_id = ? AND status = ? AND ticket_number < ?", ticket.ProjectID, ticket.QueueSessionID, model.TicketWaiting, ticket.TicketNumber).Count(&count).Error; err != nil { - writeError(w, err) - return + return nil, err } peopleAhead = int(count) } @@ -57,12 +141,11 @@ func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) { DisplayNumber string `gorm:"column:display_number"` } latestCalledNumber := any(nil) - if err := s.db.WithContext(r.Context()).Model(&model.QueueTicket{}). + if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}). Select("display_number"). Where("project_id = ? AND queue_session_id = ? AND called_at IS NOT NULL", ticket.ProjectID, ticket.QueueSessionID). Order("called_at DESC, ticket_number DESC").First(&latestCalledTicket).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - writeError(w, err) - return + return nil, err } else if latestCalledTicket.DisplayNumber != "" { latestCalledNumber = latestCalledTicket.DisplayNumber } @@ -71,13 +154,12 @@ func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) { Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && ticket.Status == model.TicketWaiting, }) if err != nil { - writeError(w, err) - return + return nil, err } if ticket.Status != model.TicketWaiting { eta = domain.ETAResult{Available: false, Reason: "ticket_not_waiting"} } - writeJSON(w, http.StatusOK, map[string]any{ + return map[string]any{ "ticket_number": ticket.DisplayNumber, "display_number": ticket.DisplayNumber, "project_name": project.Name, "status": ticket.Status, "phone_last4": phoneSuffix, "estimated_wait": eta, "visitor_notice": project.VisitorNotice, @@ -90,7 +172,7 @@ func (s *Server) publicStatus(w http.ResponseWriter, r *http.Request) { "people_ahead": peopleAhead, "queue_position": queuePosition(ticket.Status, peopleAhead), "latest_called_number": latestCalledNumber, "eta": eta, "revision": session.Revision, "server_time": s.now(), - }) + }, nil } func queuePosition(status string, peopleAhead int) any { diff --git a/server/internal/httpapi/public_test.go b/server/internal/httpapi/public_test.go index efb79f0..782450f 100644 --- a/server/internal/httpapi/public_test.go +++ b/server/internal/httpapi/public_test.go @@ -2,10 +2,12 @@ package httpapi import ( "encoding/json" + "net/http/httptest" "strings" "testing" "time" + "calllinesystem/server/internal/config" "calllinesystem/server/internal/model" ) @@ -28,3 +30,27 @@ func TestDisplayBatchDTOCannotSerializePersonalFields(t *testing.T) { t.Fatalf("public ticket number missing: %s", encoded) } } + +func TestPublicPhoneLookupIsDisabledInProduction(t *testing.T) { + server := &Server{config: config.Config{Environment: "production"}} + recorder := httptest.NewRecorder() + request := httptest.NewRequest("POST", "/api/public/status/search", strings.NewReader(`{"phone":"13800138000"}`)) + + server.publicStatusByPhone(recorder, request) + + if recorder.Code != 404 { + t.Fatalf("production phone lookup status = %d, want 404", recorder.Code) + } +} + +func TestPublicPhoneLookupRejectsInvalidPhone(t *testing.T) { + server := &Server{config: config.Config{Environment: "development"}} + recorder := httptest.NewRecorder() + request := httptest.NewRequest("POST", "/api/public/status/search", strings.NewReader(`{"phone":"123"}`)) + + server.publicStatusByPhone(recorder, request) + + if recorder.Code != 422 { + t.Fatalf("invalid phone lookup status = %d, want 422", recorder.Code) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 77c51f0..129c212 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -27,14 +27,15 @@ const ( ) type Server struct { - db *gorm.DB - config config.Config - cipher *security.Cipher - logger *slog.Logger - hub eventPublisher - loginLimiter *loginLimiter - dummyPassword string - now func() time.Time + db *gorm.DB + config config.Config + cipher *security.Cipher + logger *slog.Logger + hub eventPublisher + loginLimiter *loginLimiter + publicQueryLimiter *queryLimiter + dummyPassword string + now func() time.Time } func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) { @@ -55,14 +56,15 @@ func NewWithEventPublisher(db *gorm.DB, cfg config.Config, logger *slog.Logger, } now := func() time.Time { return time.Now().UTC() } return &Server{ - db: db, - config: cfg, - cipher: fieldCipher, - logger: logger, - hub: publisher, - loginLimiter: newLoginLimiter(now), - dummyPassword: dummy, - now: now, + db: db, + config: cfg, + cipher: fieldCipher, + logger: logger, + hub: publisher, + loginLimiter: newLoginLimiter(now), + publicQueryLimiter: newQueryLimiter(now, 120, time.Minute), + dummyPassword: dummy, + now: now, }, nil } @@ -81,6 +83,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/staff/projects/{id}/tickets", s.requireStaff(http.HandlerFunc(s.createTicket))) mux.Handle("POST /api/staff/projects/{id}/call-next", s.requireStaff(http.HandlerFunc(s.callNext))) mux.HandleFunc("GET /api/public/status/{token}", s.publicStatus) + mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone) mux.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot) mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events))) mux.Handle("GET /api/admin/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview))) diff --git a/task_plan.md b/task_plan.md index 3223c76..6c5d2bf 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 28(创建项目与项目维护表单统一) +Phase 29(游客手机号运营测试查询) ## Phases @@ -15,6 +15,19 @@ Phase 28(创建项目与项目维护表单统一) - [x] 补充前后端回归测试并完成类型检查、构建验证 - **Status:** complete +### Phase 29: 游客手机号运营测试查询 +- [x] 新增非生产手机号查询 API,复用手机号 HMAC 索引和游客状态投影 +- [x] 支持同一手机号返回多个项目的当前活动排队单,并增加基础限流 +- [x] 新增 `/visitor` 游客手机号查询页面,保留原有 `/visitor/:token` 私密状态页 +- [x] 补充前后端单元测试、真实 PostgreSQL/API 烟测和构建验证 +- **Status:** complete + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---|---| +| 前端查询页面测试把同一项目/号码在列表和详情中的重复文本当成唯一元素 | 1 | 将断言改为允许列表与详情卡同时出现 | +| 在 `server` 工作目录下使用了带 `server/` 前缀的 gofmt 路径 | 1 | 改用 `internal/httpapi/...` 相对路径后通过 | + ### Phase 27: 生产后端与数据库基础交接 - [x] 确认独立 PostgreSQL、Kubernetes 业务服务、全景区上线和 3000 峰值在线用户边界 - [x] 修复真实 PostgreSQL 烟测、管理员/手动叫号/项目筛选契约 @@ -310,6 +323,13 @@ Phase 28(创建项目与项目维护表单统一) | 本轮首次连接本地浏览器时运行时尚未初始化 | 1 | 按浏览器技能规范初始化运行时后重新连接,未影响代码验证 | | 本轮一次 Go 回归从仓库根目录执行,未找到 `server/go.mod` | 1 | 改在 `server/` 模块目录执行,时区回归测试通过 | +## Phase 30 — 手机号查询后进入独立号码页(2026-07-15) + +- 将 `/visitor` 收敛为手机号提交页,成功查询后使用 replace 导航到 `/visitor/phone`。 +- 新增无输入框的号码状态页,复用游客状态卡片和实时轮询;同手机号多张活动号码仍支持选择。 +- 保留“重新查询其他手机号”按钮,不在号码页内嵌手机号输入表单。 +- 验证通过:前端 13 个测试文件共 43 项测试、TypeScript 检查、Vite 生产构建和 `git diff --check`。 + ## Notes - 所有网络资料保留来源链接和访问时间(2026-07-10)。 - 关键架构决策前重读本计划与 findings.md。 diff --git a/web/src/App.tsx b/web/src/App.tsx index 42388de..94e2946 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,8 @@ import { DisplayPage } from "./pages/DisplayPage"; import { LoginPage } from "./pages/LoginPage"; import { StaffPage } from "./pages/StaffPage"; import { VisitorPage } from "./pages/VisitorPage"; +import { VisitorLookupPage } from "./pages/VisitorLookupPage"; +import { VisitorPhonePage } from "./pages/VisitorPhonePage"; function RequireStaff({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); @@ -34,6 +36,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/api.test.ts b/web/src/api.test.ts index dc58b90..1bd0856 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -65,6 +65,23 @@ describe("mutating queue requests", () => { }); }); +describe("public visitor lookup", () => { + it("posts the phone number and normalizes returned ticket numbers", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + tickets: [{ display_number: "00012", status: "WAITING" }], + })); + vi.stubGlobal("fetch", fetchMock); + + const response = await api.publicStatusByPhone("13800138000"); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + + expect(url).toBe("/api/public/status/search"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ phone: "13800138000" }); + expect(response.tickets[0].ticket_number).toBe("00012"); + }); +}); + describe("isolated portal authentication", () => { it("uses different endpoints for staff and admin sessions", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ user: { id: "u1", role: "STAFF" }, projects: [] })); diff --git a/web/src/api.ts b/web/src/api.ts index 487846d..0c9e525 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -7,6 +7,7 @@ import type { DisplaySnapshotDto, ProjectDto, PublicStatusDto, + PublicStatusSearchDto, QueueSnapshotDto, QueueTicketDto, CallBatchDto, @@ -137,6 +138,18 @@ export const api = { ticket_number: response.ticket_number || response.display_number || "暂无", })); }, + publicStatusByPhone(phone: string, signal?: AbortSignal) { + return request("/api/public/status/search", { + method: "POST", + body: JSON.stringify({ phone }), + signal, + }).then((response) => ({ + tickets: (response.tickets ?? []).map((ticket) => ({ + ...ticket, + ticket_number: ticket.ticket_number || ticket.display_number || "暂无", + })), + })); + }, display(token: string, signal?: AbortSignal) { return request(`/api/display/${encodeURIComponent(token)}/snapshot`, { signal }).then((response) => ({ ...response, diff --git a/web/src/pages/VisitorLookupPage.test.tsx b/web/src/pages/VisitorLookupPage.test.tsx new file mode 100644 index 0000000..91aaf9a --- /dev/null +++ b/web/src/pages/VisitorLookupPage.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePollingResource } from "../hooks/usePollingResource"; +import { VisitorLookupPage } from "./VisitorLookupPage"; + +const navigate = vi.fn(); + +vi.mock("react-router-dom", () => ({ useNavigate: () => navigate })); +vi.mock("../hooks/usePollingResource", () => ({ usePollingResource: vi.fn() })); + +const activeTickets = [ + { + project_name: "云岭漂流", + project: { id: "project-a", status: "RUNNING" }, + ticket_number: "00012", + status: "WAITING", + phone_last4: "8000", + people_ahead: 2, + latest_called_number: "00010", + estimated_wait: { min: 10, max: 15 }, + visitor_notice: "请留意现场叫号。", + }, + { + project_name: "云顶索道", + project: { id: "project-b", status: "PAUSED" }, + ticket_number: "00003", + status: "CALLED", + phone_last4: "8000", + called_at: "2026-07-15T03:00:00Z", + }, +]; + +const mockedUsePollingResource = vi.mocked(usePollingResource); + +beforeEach(() => { + navigate.mockReset(); + mockedUsePollingResource.mockImplementation(((_loader: unknown, options: { enabled?: boolean } = {}) => ({ + data: options.enabled ? { tickets: activeTickets } : undefined, + loading: false, + refreshing: false, + error: null, + offline: false, + lastClientSuccessAt: "2026-07-15T03:00:00Z", + refresh: vi.fn(), + })) as never); +}); + +describe("VisitorLookupPage", () => { + it("navigates to the number page after a successful phone query", async () => { + render(); + + fireEvent.change(screen.getByLabelText("手机号"), { target: { value: "13800138000" } }); + fireEvent.click(screen.getByRole("button", { name: "查询排队状态" })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith("/visitor/phone", { + replace: true, + state: { phone: "13800138000" }, + })); + }); + + it("rejects an invalid phone before querying", () => { + render(); + fireEvent.change(screen.getByLabelText("手机号"), { target: { value: "123" } }); + fireEvent.click(screen.getByRole("button", { name: "查询排队状态" })); + + expect(screen.getByRole("alert")).toHaveTextContent("请输入有效的手机号"); + expect(screen.queryByText("找到 2 张排队单")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/VisitorLookupPage.tsx b/web/src/pages/VisitorLookupPage.tsx new file mode 100644 index 0000000..58b4873 --- /dev/null +++ b/web/src/pages/VisitorLookupPage.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { api } from "../api"; +import { FeedbackBanner, LoadingState } from "../components/Feedback"; +import { usePollingResource } from "../hooks/usePollingResource"; + +function isPhoneInputValid(value: string): boolean { + const digits = value.replace(/\D/g, ""); + return /^[+\d\s\-()]+$/.test(value) && digits.length >= 7 && digits.length <= 15; +} + +export function VisitorLookupPage() { + const navigate = useNavigate(); + const [phoneInput, setPhoneInput] = useState(""); + const [submittedPhone, setSubmittedPhone] = useState(""); + const [formError, setFormError] = useState(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) { + event.preventDefault(); + const nextPhone = phoneInput.trim(); + if (!isPhoneInputValid(nextPhone)) { + setFormError("请输入有效的手机号(7-15 位数字)"); + return; + } + setFormError(null); + if (nextPhone === submittedPhone) { + void resource.refresh(); + return; + } + setSubmittedPhone(nextPhone); + } + + const liveTone = resource.offline ? "offline" : resource.error ? "stale" : "live"; + const liveLabel = resource.offline ? "连接中断" : resource.error ? "查询延迟" : "手机号查询"; + + return ( +
+
+
+ + 小七孔文旅集团排队服务 +
+