From d782227a23bfa4227cdc14dee5b739bbb60b3c8f Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 16:36:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E6=94=BE=E5=A4=9A=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E5=85=8D=E7=99=BB=E5=BD=95=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求描述:/admin/display 需要无需管理员登录即可直接查看多项目公开运行状态。 实现思路:新增公开字段白名单接口与独立大屏页面,移除该路由的管理员会话依赖,同时保留 /api/admin/overview 的鉴权并补充隐私、路由和页面回归测试。 验证:Go 全量测试通过;前端 18 个测试文件共 59 项通过,TypeScript 检查通过。 --- README.md | 2 +- findings.md | 9 ++++ progress.md | 11 +++++ server/internal/httpapi/public.go | 35 +++++++++++++ server/internal/httpapi/public_test.go | 27 ++++++++++ server/internal/httpapi/server.go | 1 + task_plan.md | 24 ++++++++- web/src/App.test.tsx | 40 +++++++++++++++ web/src/App.tsx | 3 +- web/src/api.test.ts | 14 ++++++ web/src/api.ts | 4 ++ web/src/pages/AdminPage.tsx | 2 +- .../pages/PublicDisplayCenterPage.test.tsx | 49 +++++++++++++++++++ web/src/pages/PublicDisplayCenterPage.tsx | 40 +++++++++++++++ web/src/types.ts | 5 ++ 15 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 web/src/App.test.tsx create mode 100644 web/src/pages/PublicDisplayCenterPage.test.tsx create mode 100644 web/src/pages/PublicDisplayCenterPage.tsx diff --git a/README.md b/README.md index 7355c5f..6078fc2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ docker compose up -d postgres - `/display/:token`:单项目只读公示屏。 - `/admin`:管理端运营概览。 - `/admin/projects`:配置项目支持的叫号方式、单号人数范围、两种方式各自的默认值与防误触单次上限,以及按单人间隔计算的预计等待时间。 -- `/admin/display`:管理端大屏中心,可全屏展示多项目的公开运行状态。 +- `/admin/display`:无需登录的多项目只读大屏中心,可全屏展示公开运行状态。 ## 质量检查 diff --git a/findings.md b/findings.md index e4c119e..13142e9 100644 --- a/findings.md +++ b/findings.md @@ -593,3 +593,12 @@ - 根因已确认:跨营业日创建新场次时,旧场次可能仍保留 `RUNNING/PAUSED`;手机号查询没有像员工队列和公屏一样选择最新场次,因此把旧、新场次中的同号同时返回。 - 修复在数据库查询层增加按项目关联的“最新活动场次”子查询,不采用结果数组去重;这样旧场次同号会被排除,当前场次中同手机号的 `00001`、`00002` 等合法不同号码仍全部返回。 - 本次不需要数据库迁移,也不修改历史数据;重新发布 API 后查询立即按新口径生效。 + +# 2026-07-31 管理端大屏中心免登录访问 + +- `/admin/display` 当前被 `AuthProvider portal="admin"` 与 `RequireAdmin` 包裹,未登录会跳转 `/admin/login`。 +- `AdminPage` 无条件轮询 `/api/admin/overview`;该接口包含 `active_tickets` 的完整手机号、姓氏等授权数据,不能直接取消后端鉴权。 +- 大屏组件实际只消费项目 ID、名称、状态、当前公开票号、等待票/人数、累计取号/体验人数、预计等待等字段。 +- 采用新公开接口返回字段白名单,并让公开路由使用独立页面壳;其他 `/admin/*` 页面和 `/api/admin/*` 接口保持原鉴权。 +- 工作区已有 `DisplayPage.tsx` 与对应测试的未提交语音播报改动,本轮不覆盖、不整理这些用户改动。 +- 线上 URL 在普通读取器中被安全校验拒绝,应用内浏览器两次只读打开均超时;没有据此推断线上当前页面状态,也未执行部署。 diff --git a/progress.md b/progress.md index 7489085..8aa44cd 100644 --- a/progress.md +++ b/progress.md @@ -678,3 +678,14 @@ - 已更新生产交接文档,补充结构化日志字段、脱敏规则、正文上限及 Ingress URI 脱敏要求。 - 最终验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./cmd/api`、`git diff --check`。 - `go test -race` 因当前 Windows Go 环境关闭 CGO 未运行;未发现由此遗留的功能失败。 + +# Session: 2026-07-31(管理端大屏中心免登录访问) + +- 已定位前端登录跳转和后端数据依赖:路由受 `RequireAdmin` 保护,页面数据来自含游客个人信息的 `/api/admin/overview`。 +- 已确定安全边界:保留所有管理接口鉴权,新增公开字段白名单接口,并为 `/admin/display` 提供不依赖管理员会话的页面。 +- 已确认工作区原有两处公示屏语音播报改动,后续实现将避开并保留。 +- 后端新增 `GET /api/display/overview`,响应只投影大屏所需的公开字段;`/api/admin/overview` 仍由 `requireAdmin` 包裹。 +- 前端新增公开大屏页面,路由不再挂载管理员 `AuthProvider` 或 `RequireAdmin`,且页面不显示退出与管理导航。 +- 聚焦验证通过:Go 公开 DTO 测试 2 项;前端路由、API、公开页面及既有管理页共 4 个文件、19 项测试;TypeScript 检查通过。 +- 全量验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./...`;前端 18 个文件、59 项测试与 Vite 生产构建全部通过;`git diff --check` 无格式错误。 +- 线上 `https://queue.nianxx.cn/admin/display` 的只读核对连续超时;本轮未获授权发布生产环境,因此线上效果需在部署本次代码后复验。 diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 3c7bb24..a3f80a3 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -444,6 +444,41 @@ func newDisplayBatchDTO(batch model.CallBatch, tickets []displayTicketDTO) displ } } +func publicDisplayProjectView(project map[string]any) map[string]any { + return map[string]any{ + "id": project["id"], + "name": project["name"], + "status": project["status"], + "waiting_count": project["waiting_count"], + "waiting_ticket_count": project["waiting_ticket_count"], + "waiting_people_count": project["waiting_people_count"], + "issued_ticket_count": project["issued_ticket_count"], + "latest_ticket_number": project["latest_ticket_number"], + "experienced_people": project["experienced_people"], + "current_batch": project["current_batch"], + "estimated_wait": project["estimated_wait"], + "last_updated_at": project["last_updated_at"], + } +} + +func (s *Server) displayOverview(w http.ResponseWriter, r *http.Request) { + var projects []model.Project + if err := s.db.WithContext(r.Context()).Order("name ASC").Find(&projects).Error; err != nil { + writeError(w, err) + return + } + views := make([]map[string]any, 0, len(projects)) + for _, project := range projects { + projection, _, _, _, _, err := s.adminProjectProjection(r.Context(), project) + if err != nil { + writeError(w, err) + return + } + views = append(views, publicDisplayProjectView(projection)) + } + writeJSON(w, http.StatusOK, map[string]any{"projects": views, "server_time": s.now()}) +} + func (s *Server) events(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(r.URL.Query().Get("project_id")) if err := validateUUID(projectID); err != nil { diff --git a/server/internal/httpapi/public_test.go b/server/internal/httpapi/public_test.go index 782450f..eb17f5a 100644 --- a/server/internal/httpapi/public_test.go +++ b/server/internal/httpapi/public_test.go @@ -31,6 +31,33 @@ func TestDisplayBatchDTOCannotSerializePersonalFields(t *testing.T) { } } +func TestPublicDisplayProjectViewOnlySerializesDisplayFields(t *testing.T) { + view := publicDisplayProjectView(map[string]any{ + "id": "project-1", "name": "东门观光车", "status": model.ProjectRunning, + "waiting_count": 3, "waiting_ticket_count": 3, "waiting_people_count": 7, + "issued_ticket_count": 15, "latest_ticket_number": "00015", "experienced_people": 12, + "current_batch": map[string]any{"tickets": []map[string]any{{"ticket_number": "00013"}}}, + "estimated_wait": map[string]any{"available": true}, "last_updated_at": time.Unix(100, 0).UTC(), + "phone": "13800138000", "last_name": "张", "visitor_notice": "internal", + "device_status": map[string]any{"status": "FAILURE"}, "display_token_hash": "secret", + }) + body, err := json.Marshal(view) + if err != nil { + t.Fatal(err) + } + encoded := string(body) + for _, forbidden := range []string{"phone", "last_name", "visitor_notice", "device_status", "display_token_hash", "13800138000"} { + if strings.Contains(encoded, forbidden) { + t.Fatalf("public display overview leaked forbidden field %q: %s", forbidden, encoded) + } + } + for _, required := range []string{`"name":"东门观光车"`, `"ticket_number":"00013"`, `"waiting_people_count":7`} { + if !strings.Contains(encoded, required) { + t.Fatalf("public display overview missing field %q: %s", required, encoded) + } + } +} + func TestPublicPhoneLookupIsDisabledInProduction(t *testing.T) { server := &Server{config: config.Config{Environment: "production"}} recorder := httptest.NewRecorder() diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 9f1c0a9..b72ad0d 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -89,6 +89,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket) mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone) mux.Handle("POST /api/internal/status/search", s.requireInternalNetwork(http.HandlerFunc(s.internalStatusByPhone))) + mux.HandleFunc("GET /api/display/overview", s.displayOverview) 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 4fb86f0..651cf04 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 42(运营统计模块标题区呼吸空间优化) +Phase 45(管理端大屏中心免登录访问) ## Phases @@ -550,3 +550,25 @@ Phase 42(运营统计模块标题区呼吸空间优化) | Error | Attempt | Resolution | |---|---:|---| | PowerShell 下向 `rg` 传入 Unix 风格 `*_test.go` 路径通配符失败 | 1 | 后续改由 `rg` 自身的 `-g` 过滤文件,不再让 Windows 解析目录通配符 | + +### Phase 45: 管理端大屏中心免登录访问(2026-07-31) +- [x] 核对 `/admin/display` 前端路由、页面依赖与后端鉴权边界 +- [x] 新增仅包含公开大屏字段的免登录接口 +- [x] 将 `/admin/display` 切换为无需管理员会话的独立页面 +- [x] 补充路由、公开字段白名单与页面回归测试 +- [x] 完成前后端测试、类型检查和生产构建验证 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 管理端总览接口 | `/api/admin/overview` 继续强制管理员登录 | 该接口含活动游客联系方式和后台配置,不能为大屏免登录而整体放开 | +| 公开大屏数据 | 新增字段白名单接口,仅返回项目名、公开票号、等待量、预计等待与刷新时间 | 满足多项目大屏展示,同时维持隐私与后台权限边界 | +| 前端入口 | `/admin/display` 使用独立公开页面,不挂载管理员鉴权 Provider | 未登录访问不产生登录跳转或无意义的管理员会话探测 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---:|---| +| 首次组合补丁把内部网络路由误写为 `HandleFunc`,上下文未匹配 | 1 | 读取真实路由片段后按现有 `Handle` 行重新应用,未产生部分代码改动 | +| 普通网页读取器因域名安全校验拒绝打开线上 URL | 1 | 改用只读应用内浏览器核对,不绕过安全校验 | +| 应用内浏览器两次打开线上 URL 均超时 | 2 | 按有界重试停止继续访问;保留“代码已完成、线上尚未部署验证”的明确边界 | diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..8868654 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { authProvider } = vi.hoisted(() => ({ + authProvider: vi.fn(({ children }: { children: React.ReactNode }) => <>{children}), +})); + +vi.mock("./auth/AuthContext", () => ({ + AuthProvider: authProvider, + useAuth: () => ({ user: null, loading: false }), +})); +vi.mock("./pages/PublicDisplayCenterPage", () => ({ + PublicDisplayCenterPage: () =>
公开大屏页面
, +})); +vi.mock("./pages/AdminPage", () => ({ AdminPage: () =>
管理页面
})); +vi.mock("./pages/DisplayPage", () => ({ DisplayPage: () =>
单项目公屏
})); +vi.mock("./pages/HistoryPage", () => ({ HistoryPage: () =>
历史页面
})); +vi.mock("./pages/LoginPage", () => ({ LoginPage: () =>
登录页面
})); +vi.mock("./pages/StaffPage", () => ({ StaffPage: () =>
员工页面
})); +vi.mock("./pages/VisitorPage", () => ({ VisitorPage: () =>
游客页面
})); +vi.mock("./pages/VisitorLookupPage", () => ({ VisitorLookupPage: () =>
游客查询
})); +vi.mock("./pages/VisitorPhonePage", () => ({ VisitorPhonePage: () =>
游客号码
})); + +import { App } from "./App"; + +afterEach(() => { + window.history.pushState({}, "", "/"); + vi.clearAllMocks(); +}); + +describe("App routes", () => { + it("opens /admin/display without mounting administrator authentication", () => { + window.history.pushState({}, "", "/admin/display"); + + render(); + + expect(screen.getByText("公开大屏页面")).toBeVisible(); + expect(authProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 5d0acac..eb57afb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from "./auth/AuthContext"; import { LoadingState } from "./components/Feedback"; import { AdminPage } from "./pages/AdminPage"; import { DisplayPage } from "./pages/DisplayPage"; +import { PublicDisplayCenterPage } from "./pages/PublicDisplayCenterPage"; import { LoginPage } from "./pages/LoginPage"; import { HistoryPage } from "./pages/HistoryPage"; import { StaffPage } from "./pages/StaffPage"; @@ -50,7 +51,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/web/src/api.test.ts b/web/src/api.test.ts index ecbacf3..4436f69 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -108,6 +108,20 @@ describe("public visitor lookup", () => { }); }); +describe("public display overview", () => { + it("loads the display wall without using an admin endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + projects: [{ id: "project-1", name: "东门观光车", status: "RUNNING" }], + server_time: "2026-07-31T08:00:00Z", + })); + vi.stubGlobal("fetch", fetchMock); + + await api.displayOverview(); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/display/overview"); + }); +}); + 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 ecbd8fd..5196a22 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -4,6 +4,7 @@ import type { CallNextResponse, CreateTicketPayload, CreateTicketResponse, + DisplayOverviewDto, DisplaySnapshotDto, ProjectDto, PublicStatusDto, @@ -222,6 +223,9 @@ export const api = { recent_batches: (response.recent_batches ?? []).map((batch) => normalizeBatch(batch) as CallBatchDto), })); }, + displayOverview(signal?: AbortSignal) { + return request("/api/display/overview", { signal }); + }, adminOverview(signal?: AbortSignal) { return request("/api/admin/overview", { signal }); }, diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 21db315..146b45c 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -357,7 +357,7 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; ; } -function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) { +export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) { const [fullscreenError, setFullscreenError] = useState(false); const enterFullscreen = async (projectId: string) => { const tile = document.querySelector(`[data-project-screen="${projectId}"]`); diff --git a/web/src/pages/PublicDisplayCenterPage.test.tsx b/web/src/pages/PublicDisplayCenterPage.test.tsx new file mode 100644 index 0000000..9aecc31 --- /dev/null +++ b/web/src/pages/PublicDisplayCenterPage.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const { displayOverview } = vi.hoisted(() => ({ displayOverview: vi.fn() })); + +vi.mock("../api", () => ({ api: { displayOverview } })); +vi.mock("../hooks/usePollingResource", () => ({ + usePollingResource: (loader: (signal: AbortSignal) => unknown) => { + loader(new AbortController().signal); + return { + data: { + projects: [{ + id: "project-1", + name: "东门观光车", + status: "RUNNING", + waiting_count: 1, + waiting_ticket_count: 1, + waiting_people_count: 3, + issued_ticket_count: 15, + latest_ticket_number: "00015", + experienced_people: 12, + current_batch: null, + estimated_wait: { available: true, lower_minutes: 5, upper_minutes: 15 }, + }], + server_time: "2026-07-31T08:00:00Z", + }, + loading: false, + refreshing: false, + error: null, + offline: false, + lastClientSuccessAt: new Date().toISOString(), + refresh: vi.fn(), + }; + }, +})); + +import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage"; + +describe("PublicDisplayCenterPage", () => { + it("uses public data and renders without administrator controls", () => { + render(); + + expect(displayOverview).toHaveBeenCalledOnce(); + expect(screen.getByRole("region", { name: "项目实时监控墙" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "退出" })).not.toBeInTheDocument(); + expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/PublicDisplayCenterPage.tsx b/web/src/pages/PublicDisplayCenterPage.tsx new file mode 100644 index 0000000..6439f85 --- /dev/null +++ b/web/src/pages/PublicDisplayCenterPage.tsx @@ -0,0 +1,40 @@ +import { api } from "../api"; +import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback"; +import { usePollingResource } from "../hooks/usePollingResource"; +import { isTimestampStale } from "../lib/format"; +import { DisplayCenter } from "./AdminPage"; + +export function PublicDisplayCenterPage() { + const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 }); + const data = resource.data; + const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000); + + return ( +
+ 跳到主要内容 +
+
+ + 景区排队叫号系统 +
+
大屏中心
+
+
+
+ {resource.loading && !data ? : null} + {resource.error && !data ? ( + 重试}> + {resource.error.message} + + ) : null} + {data ? ( + <> + + + + ) : null} +
+
+
+ ); +} diff --git a/web/src/types.ts b/web/src/types.ts index 53a8f96..691b995 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -206,6 +206,11 @@ export interface AdminProjectDto extends Partial { device_status?: unknown; } +export interface DisplayOverviewDto { + projects: AdminProjectDto[]; + server_time: string; +} + export interface UpdateProjectSettingsPayload { status: string; call_mode: CallMode;