开放多项目大屏免登录访问
需求描述:/admin/display 需要无需管理员登录即可直接查看多项目公开运行状态。 实现思路:新增公开字段白名单接口与独立大屏页面,移除该路由的管理员会话依赖,同时保留 /api/admin/overview 的鉴权并补充隐私、路由和页面回归测试。 验证:Go 全量测试通过;前端 18 个测试文件共 59 项通过,TypeScript 检查通过。
This commit is contained in:
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 { 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() {
|
||||
<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/: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 />} />
|
||||
</Routes>
|
||||
</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", () => {
|
||||
it("uses different endpoints for staff and admin sessions", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ user: { id: "u1", role: "STAFF" }, projects: [] }));
|
||||
|
||||
@@ -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<DisplayOverviewDto>("/api/display/overview", { signal });
|
||||
},
|
||||
adminOverview(signal?: AbortSignal) {
|
||||
return request<AdminOverviewDto>("/api/admin/overview", { signal });
|
||||
},
|
||||
|
||||
@@ -357,7 +357,7 @@ function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto;
|
||||
</div>;
|
||||
}
|
||||
|
||||
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}"]`);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface DisplayOverviewDto {
|
||||
projects: AdminProjectDto[];
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectSettingsPayload {
|
||||
status: string;
|
||||
call_mode: CallMode;
|
||||
|
||||
Reference in New Issue
Block a user