开放多项目大屏免登录访问
需求描述:/admin/display 需要无需管理员登录即可直接查看多项目公开运行状态。 实现思路:新增公开字段白名单接口与独立大屏页面,移除该路由的管理员会话依赖,同时保留 /api/admin/overview 的鉴权并补充隐私、路由和页面回归测试。 验证:Go 全量测试通过;前端 18 个测试文件共 59 项通过,TypeScript 检查通过。
This commit is contained in:
@@ -76,7 +76,7 @@ docker compose up -d postgres
|
|||||||
- `/display/:token`:单项目只读公示屏。
|
- `/display/:token`:单项目只读公示屏。
|
||||||
- `/admin`:管理端运营概览。
|
- `/admin`:管理端运营概览。
|
||||||
- `/admin/projects`:配置项目支持的叫号方式、单号人数范围、两种方式各自的默认值与防误触单次上限,以及按单人间隔计算的预计等待时间。
|
- `/admin/projects`:配置项目支持的叫号方式、单号人数范围、两种方式各自的默认值与防误触单次上限,以及按单人间隔计算的预计等待时间。
|
||||||
- `/admin/display`:管理端大屏中心,可全屏展示多项目的公开运行状态。
|
- `/admin/display`:无需登录的多项目只读大屏中心,可全屏展示公开运行状态。
|
||||||
|
|
||||||
## 质量检查
|
## 质量检查
|
||||||
|
|
||||||
|
|||||||
@@ -593,3 +593,12 @@
|
|||||||
- 根因已确认:跨营业日创建新场次时,旧场次可能仍保留 `RUNNING/PAUSED`;手机号查询没有像员工队列和公屏一样选择最新场次,因此把旧、新场次中的同号同时返回。
|
- 根因已确认:跨营业日创建新场次时,旧场次可能仍保留 `RUNNING/PAUSED`;手机号查询没有像员工队列和公屏一样选择最新场次,因此把旧、新场次中的同号同时返回。
|
||||||
- 修复在数据库查询层增加按项目关联的“最新活动场次”子查询,不采用结果数组去重;这样旧场次同号会被排除,当前场次中同手机号的 `00001`、`00002` 等合法不同号码仍全部返回。
|
- 修复在数据库查询层增加按项目关联的“最新活动场次”子查询,不采用结果数组去重;这样旧场次同号会被排除,当前场次中同手机号的 `00001`、`00002` 等合法不同号码仍全部返回。
|
||||||
- 本次不需要数据库迁移,也不修改历史数据;重新发布 API 后查询立即按新口径生效。
|
- 本次不需要数据库迁移,也不修改历史数据;重新发布 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 在普通读取器中被安全校验拒绝,应用内浏览器两次只读打开均超时;没有据此推断线上当前页面状态,也未执行部署。
|
||||||
|
|||||||
11
progress.md
11
progress.md
@@ -678,3 +678,14 @@
|
|||||||
- 已更新生产交接文档,补充结构化日志字段、脱敏规则、正文上限及 Ingress URI 脱敏要求。
|
- 已更新生产交接文档,补充结构化日志字段、脱敏规则、正文上限及 Ingress URI 脱敏要求。
|
||||||
- 最终验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./cmd/api`、`git diff --check`。
|
- 最终验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./cmd/api`、`git diff --check`。
|
||||||
- `go test -race` 因当前 Windows Go 环境关闭 CGO 未运行;未发现由此遗留的功能失败。
|
- `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` 的只读核对连续超时;本轮未获授权发布生产环境,因此线上效果需在部署本次代码后复验。
|
||||||
|
|||||||
@@ -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) {
|
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
|
projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
|
||||||
if err := validateUUID(projectID); err != nil {
|
if err := validateUUID(projectID); err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestPublicPhoneLookupIsDisabledInProduction(t *testing.T) {
|
||||||
server := &Server{config: config.Config{Environment: "production"}}
|
server := &Server{config: config.Config{Environment: "production"}}
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -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/projects/{id}/tickets", s.publicCreateTicket)
|
||||||
mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone)
|
mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone)
|
||||||
mux.Handle("POST /api/internal/status/search", s.requireInternalNetwork(http.HandlerFunc(s.internalStatusByPhone)))
|
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.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot)
|
||||||
mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events)))
|
mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events)))
|
||||||
mux.Handle("GET /api/admin/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview)))
|
mux.Handle("GET /api/admin/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview)))
|
||||||
|
|||||||
24
task_plan.md
24
task_plan.md
@@ -4,7 +4,7 @@
|
|||||||
在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。
|
在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
Phase 42(运营统计模块标题区呼吸空间优化)
|
Phase 45(管理端大屏中心免登录访问)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -550,3 +550,25 @@ Phase 42(运营统计模块标题区呼吸空间优化)
|
|||||||
| Error | Attempt | Resolution |
|
| Error | Attempt | Resolution |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| PowerShell 下向 `rg` 传入 Unix 风格 `*_test.go` 路径通配符失败 | 1 | 后续改由 `rg` 自身的 `-g` 过滤文件,不再让 Windows 解析目录通配符 |
|
| 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 | 按有界重试停止继续访问;保留“代码已完成、线上尚未部署验证”的明确边界 |
|
||||||
|
|||||||
40
web/src/App.test.tsx
Normal file
40
web/src/App.test.tsx
Normal file
@@ -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: () => <div>公开大屏页面</div>,
|
||||||
|
}));
|
||||||
|
vi.mock("./pages/AdminPage", () => ({ AdminPage: () => <div>管理页面</div> }));
|
||||||
|
vi.mock("./pages/DisplayPage", () => ({ DisplayPage: () => <div>单项目公屏</div> }));
|
||||||
|
vi.mock("./pages/HistoryPage", () => ({ HistoryPage: () => <div>历史页面</div> }));
|
||||||
|
vi.mock("./pages/LoginPage", () => ({ LoginPage: () => <div>登录页面</div> }));
|
||||||
|
vi.mock("./pages/StaffPage", () => ({ StaffPage: () => <div>员工页面</div> }));
|
||||||
|
vi.mock("./pages/VisitorPage", () => ({ VisitorPage: () => <div>游客页面</div> }));
|
||||||
|
vi.mock("./pages/VisitorLookupPage", () => ({ VisitorLookupPage: () => <div>游客查询</div> }));
|
||||||
|
vi.mock("./pages/VisitorPhonePage", () => ({ VisitorPhonePage: () => <div>游客号码</div> }));
|
||||||
|
|
||||||
|
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(<App />);
|
||||||
|
|
||||||
|
expect(screen.getByText("公开大屏页面")).toBeVisible();
|
||||||
|
expect(authProvider).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from "./auth/AuthContext";
|
|||||||
import { LoadingState } from "./components/Feedback";
|
import { LoadingState } from "./components/Feedback";
|
||||||
import { AdminPage } from "./pages/AdminPage";
|
import { AdminPage } from "./pages/AdminPage";
|
||||||
import { DisplayPage } from "./pages/DisplayPage";
|
import { DisplayPage } from "./pages/DisplayPage";
|
||||||
|
import { PublicDisplayCenterPage } from "./pages/PublicDisplayCenterPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { HistoryPage } from "./pages/HistoryPage";
|
import { HistoryPage } from "./pages/HistoryPage";
|
||||||
import { StaffPage } from "./pages/StaffPage";
|
import { StaffPage } from "./pages/StaffPage";
|
||||||
@@ -50,7 +51,7 @@ export function App() {
|
|||||||
<Route path="/admin/accounts" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
<Route path="/admin/accounts" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
||||||
<Route path="/admin/accounts/new" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
<Route path="/admin/accounts/new" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
||||||
<Route path="/admin/accounts/:accountId" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
<Route path="/admin/accounts/:accountId" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
||||||
<Route path="/admin/display" element={<AuthProvider portal="admin"><RequireAdmin><AdminPage /></RequireAdmin></AuthProvider>} />
|
<Route path="/admin/display" element={<PublicDisplayCenterPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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", () => {
|
describe("isolated portal authentication", () => {
|
||||||
it("uses different endpoints for staff and admin sessions", async () => {
|
it("uses different endpoints for staff and admin sessions", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ user: { id: "u1", role: "STAFF" }, projects: [] }));
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ user: { id: "u1", role: "STAFF" }, projects: [] }));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CallNextResponse,
|
CallNextResponse,
|
||||||
CreateTicketPayload,
|
CreateTicketPayload,
|
||||||
CreateTicketResponse,
|
CreateTicketResponse,
|
||||||
|
DisplayOverviewDto,
|
||||||
DisplaySnapshotDto,
|
DisplaySnapshotDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
PublicStatusDto,
|
PublicStatusDto,
|
||||||
@@ -222,6 +223,9 @@ export const api = {
|
|||||||
recent_batches: (response.recent_batches ?? []).map((batch) => normalizeBatch(batch) as CallBatchDto),
|
recent_batches: (response.recent_batches ?? []).map((batch) => normalizeBatch(batch) as CallBatchDto),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
displayOverview(signal?: AbortSignal) {
|
||||||
|
return request<DisplayOverviewDto>("/api/display/overview", { signal });
|
||||||
|
},
|
||||||
adminOverview(signal?: AbortSignal) {
|
adminOverview(signal?: AbortSignal) {
|
||||||
return request<AdminOverviewDto>("/api/admin/overview", { signal });
|
return request<AdminOverviewDto>("/api/admin/overview", { signal });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
|
|||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
|
export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
|
||||||
const [fullscreenError, setFullscreenError] = useState(false);
|
const [fullscreenError, setFullscreenError] = useState(false);
|
||||||
const enterFullscreen = async (projectId: string) => {
|
const enterFullscreen = async (projectId: string) => {
|
||||||
const tile = document.querySelector(`[data-project-screen="${projectId}"]`);
|
const tile = document.querySelector(`[data-project-screen="${projectId}"]`);
|
||||||
|
|||||||
49
web/src/pages/PublicDisplayCenterPage.test.tsx
Normal file
49
web/src/pages/PublicDisplayCenterPage.test.tsx
Normal file
@@ -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(<PublicDisplayCenterPage />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
web/src/pages/PublicDisplayCenterPage.tsx
Normal file
40
web/src/pages/PublicDisplayCenterPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||||||
|
<a className="skip-link" href="#main-content">跳到主要内容</a>
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand-lockup" aria-label="景区排队叫号系统">
|
||||||
|
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>
|
||||||
|
<span className="brand-lockup__text"><strong>景区排队叫号系统</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="account-menu"><span><strong>大屏中心</strong></span></div>
|
||||||
|
</header>
|
||||||
|
<main id="main-content" className="app-main">
|
||||||
|
<section className="admin-content" aria-label="公开大屏内容">
|
||||||
|
{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.message}
|
||||||
|
</FeedbackBanner>
|
||||||
|
) : null}
|
||||||
|
{data ? (
|
||||||
|
<>
|
||||||
|
<FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} />
|
||||||
|
<DisplayCenter projects={data.projects} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -206,6 +206,11 @@ export interface AdminProjectDto extends Partial<ProjectDto> {
|
|||||||
device_status?: unknown;
|
device_status?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DisplayOverviewDto {
|
||||||
|
projects: AdminProjectDto[];
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateProjectSettingsPayload {
|
export interface UpdateProjectSettingsPayload {
|
||||||
status: string;
|
status: string;
|
||||||
call_mode: CallMode;
|
call_mode: CallMode;
|
||||||
|
|||||||
Reference in New Issue
Block a user