开放多项目大屏免登录访问

需求描述:/admin/display 需要无需管理员登录即可直接查看多项目公开运行状态。

实现思路:新增公开字段白名单接口与独立大屏页面,移除该路由的管理员会话依赖,同时保留 /api/admin/overview 的鉴权并补充隐私、路由和页面回归测试。

验证:Go 全量测试通过;前端 18 个测试文件共 59 项通过,TypeScript 检查通过。
This commit is contained in:
2026-07-31 16:36:03 +08:00
parent 17d296263c
commit d782227a23
15 changed files with 262 additions and 4 deletions

View File

@@ -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}"]`);

View 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();
});
});

View 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>
);
}