Files
XQKqueue/web/src/pages/AdminPage.tsx
brother7 d782227a23 开放多项目大屏免登录访问
需求描述:/admin/display 需要无需管理员登录即可直接查看多项目公开运行状态。

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

验证:Go 全量测试通过;前端 18 个测试文件共 59 项通过,TypeScript 检查通过。
2026-07-31 16:36:03 +08:00

585 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, type FormEvent } from "react";
import { Link, NavLink, useLocation, useNavigate } from "react-router-dom";
import { ApiError, api } from "../api";
import { AppShell } from "../components/AppShell";
import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { StatusBadge } from "../components/StatusBadge";
import { usePollingResource } from "../hooks/usePollingResource";
import { formatDateTime, formatTicketNumberRange, isTimestampStale } from "../lib/format";
import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto, CallMode } from "../types";
import { forecastRows } from "./DisplayPage";
function currentBatchLabel(value: AdminProjectDto["current_batch"]): string {
if (value === null || value === undefined) return "暂无";
if (typeof value === "string" || typeof value === "number") return String(value);
const batch = value as CallBatchDto;
return formatTicketNumberRange(batch.tickets.map((ticket) => ticket.ticket_number));
}
function currentBatchNumbers(value: AdminProjectDto["current_batch"]): string {
if (!value || typeof value === "string" || typeof value === "number") return currentBatchLabel(value);
const batch = value as CallBatchDto;
const numbers = batch.tickets.map((ticket) => ticket.ticket_number).filter(Boolean);
return numbers.length ? formatTicketNumberRange(numbers) : currentBatchLabel(batch);
}
function deviceStatusLabel(value: unknown): string {
if (!value) return "未配置";
if (typeof value === "string") return value;
if (typeof value === "object") {
const record = value as Record<string, unknown>;
return String(record.label ?? record.status ?? record.message ?? "已配置");
}
return String(value);
}
function AdminNavigation() {
return (
<aside className="admin-sidebar" aria-label="管理端导航">
<nav className="admin-nav" aria-label="管理任务">
<NavLink end to="/admin"></NavLink>
<NavLink to="/admin/projects"></NavLink>
<NavLink to="/admin/history"></NavLink>
<NavLink to="/admin/accounts"></NavLink>
<NavLink to="/admin/display"></NavLink>
</nav>
</aside>
);
}
function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOverviewDto; refreshing: boolean; onRefresh: () => void }) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const projects = data.projects.filter((project) => {
const matchesQuery = project.name.toLowerCase().includes(query.trim().toLowerCase());
return matchesQuery && (statusFilter === "ALL" || project.status === statusFilter);
});
const summary = data.summary;
return (
<div className="operations-dashboard">
<div className="operations-dashboard__overview">
<section className="summary-strip" aria-label="运营汇总">
<article><span></span><strong>{summary.running_projects}</strong><small></small></article>
<article><span></span><strong>{summary.waiting_people_count ?? 0}</strong><small></small></article>
</section>
</div>
<div className="operations-dashboard__body">
<section className="panel queue-overview" aria-labelledby="admin-projects-title">
<div className="queue-overview__heading">
<div><span className="section-marker" aria-hidden="true" /><h2 id="admin-projects-title"></h2></div>
<button className="button button--secondary button--small" onClick={onRefresh} disabled={refreshing}>{refreshing ? "正在更新" : "刷新数据"}</button>
</div>
<div className="queue-overview__filters">
<label><span className="sr-only"></span><select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="ALL"></option><option value="RUNNING"></option><option value="PAUSED"></option><option value="ENDED"></option></select></label>
<label className="queue-search"><span className="sr-only"></span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目名称" /></label>
</div>
{projects.length ? (
<>
<div className="queue-project-list" role="list" aria-label="项目队列">
<div className="queue-project-list__header" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
{projects.map((project) => (
<div className="queue-project-row" key={project.id} role="listitem">
<span className="queue-project-row__name"><strong>{project.name}</strong><small>{deviceStatusLabel(project.device_status)}</small></span>
<span className="queue-project-row__metric"><strong>{project.waiting_people_count ?? 0}</strong><small> · {project.waiting_ticket_count ?? project.waiting_count ?? 0} </small></span>
<span><strong>{currentBatchLabel(project.current_batch)}</strong></span>
<StatusBadge status={project.status} kind="project" />
<time>{formatDateTime(project.last_updated_at)}</time>
</div>
))}
</div>
<footer className="queue-overview__footer"><span> {projects.length} </span><span aria-label="当前第 1 页"> <strong>1</strong> </span></footer>
</>
) : <EmptyState title="没有符合条件的项目" />}
</section>
</div>
</div>
);
}
const DEFAULT_PROJECT_TIMEZONE = "Asia/Shanghai";
const DEFAULT_TICKET_PREFIX = "A";
const DEFAULT_VISITOR_NOTICE = "请您在景区附近等候,注意听从工作人员指引。";
type ProjectDraft = {
name: string;
code: string;
status: string;
callMode: CallMode;
defaultCallTicketCount: string;
maxCallTicketCount: string;
defaultCallPeopleCount: string;
maxCallPeopleCount: string;
minPartySize: string;
maxPartySize: string;
gracePeriodMinutes: string;
experiencedPeopleStart: string;
etaIntervalSeconds: string;
visitorNotice: string;
};
function draftFor(project?: AdminProjectDto): ProjectDraft {
return {
name: project?.name ?? "",
code: project?.code ?? "",
status: project?.status ?? "NOT_OPEN",
callMode: project?.call_mode ?? "BOTH",
defaultCallTicketCount: String(project?.default_call_ticket_count ?? project?.call_batch_size ?? project?.batch_size ?? 1),
maxCallTicketCount: String(project?.max_call_ticket_count ?? 100),
defaultCallPeopleCount: String(project?.default_call_people_count ?? 1),
maxCallPeopleCount: String(project?.max_call_people_count ?? 100),
minPartySize: String(project?.min_party_size ?? 1),
maxPartySize: String(project?.max_party_size ?? 10),
gracePeriodMinutes: String(project?.grace_period_minutes ?? 5),
experiencedPeopleStart: String(project?.experienced_people_start ?? 0),
etaIntervalSeconds: String(project?.eta?.interval_per_person_seconds ?? 60),
visitorNotice: project ? project.visitor_notice ?? "" : DEFAULT_VISITOR_NOTICE,
};
}
function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise<void> }) {
const navigate = useNavigate();
const [draft, setDraft] = useState(() => draftFor(project));
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState<{ tone: "success" | "danger"; message?: string } | null>(null);
const update = (patch: Partial<ProjectDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
setNotice(null);
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
setNotice(null);
try {
const profile = {
name: draft.name,
code: draft.code.toUpperCase(),
timezone: project?.timezone ?? DEFAULT_PROJECT_TIMEZONE,
ticket_prefix: project?.ticket_prefix ?? DEFAULT_TICKET_PREFIX,
};
let projectId = project?.id;
if (projectId) {
await api.updateProject(projectId, profile);
} else {
const response = await api.createProject(profile);
projectId = response.project.id;
}
if (!projectId) throw new Error("项目创建未返回项目编号。");
await api.updateProjectSettings(projectId, {
status: draft.status,
call_mode: draft.callMode,
default_call_ticket_count: Number(draft.defaultCallTicketCount),
max_call_ticket_count: Number(draft.maxCallTicketCount),
default_call_people_count: Number(draft.defaultCallPeopleCount),
max_call_people_count: Number(draft.maxCallPeopleCount),
min_party_size: Number(draft.minPartySize),
max_party_size: Number(draft.maxPartySize),
grace_period_minutes: Number(draft.gracePeriodMinutes),
experienced_people_start: Number(draft.experiencedPeopleStart),
eta_interval_seconds: Number(draft.etaIntervalSeconds),
visitor_notice: draft.visitorNotice,
});
await onSaved();
if (project) {
setNotice({ tone: "success", message: "项目维护内容已更新。" });
} else {
navigate("/admin/projects", { replace: true });
}
} catch (caught) {
setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "项目保存失败,请检查后重试。" });
} finally {
setSaving(false);
}
};
return (
<form className="panel project-settings-form" aria-label={project ? "维护项目" : "创建项目"} onSubmit={submit}>
{!project ? <div className="panel__header"><div><h2></h2><p></p></div></div> : null}
<div className="project-form-sections">
<section className="project-form-section" aria-labelledby="project-form-basics-title">
<header className="project-form-section__header">
<h3 id="project-form-basics-title"></h3>
<p></p>
</header>
<div className="project-form-section__grid">
<label className="field">
<span></span>
<input autoFocus={!project} value={draft.name} onChange={(event) => update({ name: event.target.value })} maxLength={120} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input value={draft.code} onChange={(event) => update({ code: event.target.value.toUpperCase() })} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" disabled={saving} required />
</label>
<label className="field">
<span></span>
<select value="00000" disabled><option value="00000">00000</option></select>
</label>
<label className="field">
<span></span>
<select value={draft.status} onChange={(event) => update({ status: event.target.value })} disabled={saving}>
<option value="NOT_OPEN"></option>
<option value="RUNNING"></option>
<option value="PAUSED"></option>
<option value="ENDED"></option>
</select>
</label>
</div>
</section>
<section className="project-form-section" aria-labelledby="project-form-call-rules-title">
<header className="project-form-section__header">
<h3 id="project-form-call-rules-title"></h3>
<p></p>
</header>
<div className="project-call-rules">
<div className="project-call-rule" role="group" aria-labelledby="project-call-mode-title">
<div className="project-call-rule__heading">
<h4 id="project-call-mode-title"></h4>
<p>使</p>
</div>
<div className="project-call-rule__fields project-call-rule__fields--single">
<label className="field">
<span></span>
<select aria-label="支持的叫号方式" value={draft.callMode} onChange={(event) => update({ callMode: event.target.value as CallMode })} disabled={saving}>
<option value="TICKET"></option>
<option value="PEOPLE"></option>
<option value="BOTH"></option>
</select>
</label>
</div>
</div>
<div className="project-call-rule" role="group" aria-labelledby="project-party-size-title">
<div className="project-call-rule__heading">
<h4 id="project-party-size-title"></h4>
<p></p>
</div>
<div className="project-call-rule__fields">
<label className="field">
<span></span>
<input aria-label="单号最少人数" type="number" min="1" max={draft.maxPartySize || "10000"} value={draft.minPartySize} onChange={(event) => update({ minPartySize: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="单号最多人数" type="number" min={draft.minPartySize || "1"} max="10000" value={draft.maxPartySize} onChange={(event) => update({ maxPartySize: event.target.value })} disabled={saving} required />
</label>
</div>
</div>
<div className="project-call-rule" role="group" aria-labelledby="project-ticket-call-title">
<div className="project-call-rule__heading">
<h4 id="project-ticket-call-title"></h4>
<p></p>
</div>
<div className="project-call-rule__fields">
<label className="field">
<span></span>
<input aria-label="批量叫号默认数量" type="number" min="1" max={draft.maxCallTicketCount || "10000"} value={draft.defaultCallTicketCount} onChange={(event) => update({ defaultCallTicketCount: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="批量叫号单次上限" type="number" min={draft.defaultCallTicketCount || "1"} max="10000" value={draft.maxCallTicketCount} onChange={(event) => update({ maxCallTicketCount: event.target.value })} disabled={saving} required />
<small></small>
</label>
</div>
</div>
<div className="project-call-rule" role="group" aria-labelledby="project-people-call-title">
<div className="project-call-rule__heading">
<h4 id="project-people-call-title"></h4>
<p></p>
</div>
<div className="project-call-rule__fields">
<label className="field">
<span></span>
<input aria-label="批量叫人默认人数" type="number" min="1" max={draft.maxCallPeopleCount || "10000"} value={draft.defaultCallPeopleCount} onChange={(event) => update({ defaultCallPeopleCount: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="批量叫人单次上限" type="number" min={draft.defaultCallPeopleCount || "1"} max="10000" value={draft.maxCallPeopleCount} onChange={(event) => update({ maxCallPeopleCount: event.target.value })} disabled={saving} required />
<small></small>
</label>
</div>
</div>
</div>
</section>
<section className="project-form-section" aria-labelledby="project-form-other-rules-title">
<header className="project-form-section__header">
<h3 id="project-form-other-rules-title"></h3>
<p></p>
</header>
<div className="project-form-section__grid">
<label className="field">
<span></span>
<input aria-label="已体验人数起始展示数" type="number" min="0" max="1000000000" value={draft.experiencedPeopleStart} onChange={(event) => update({ experiencedPeopleStart: event.target.value })} disabled={saving} required />
<small></small>
</label>
<label className="field">
<span></span>
<input aria-label="每人预计间隔时间(秒)" type="number" min="1" max="86400" value={draft.etaIntervalSeconds} onChange={(event) => update({ etaIntervalSeconds: event.target.value })} disabled={saving} required />
<small></small>
</label>
<label className="field project-settings-form__visitor-notice">
<span></span>
<textarea aria-label="游客官方提示" value={draft.visitorNotice} onChange={(event) => update({ visitorNotice: event.target.value })} maxLength={240} rows={3} disabled={saving} placeholder="例如:请在入口附近等候,叫号后凭号码入场。" />
<small> 240 </small>
</label>
</div>
</section>
</div>
<div className="project-settings-form__actions">
{notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "项目保存成功" : "保存失败"}>{notice.message || null}</FeedbackBanner> : null}
{!project ? <Link className="button button--secondary" to="/admin/projects"></Link> : null}
<button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : project ? "保存项目" : "创建项目"}</button>
</div>
</form>
);
}
function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) {
return <section className="panel">
<div className="panel__header"><div><h2></h2><p></p></div><Link className="button button--primary" to="/admin/projects/new"></Link></div>
{projects.length ? <div className="project-list" role="table" aria-label="项目列表">
<div className="project-list__row project-list__header" role="row"><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span></div>
{projects.map((project) => <div className="project-list__row" role="row" key={project.id}><strong role="cell">{project.name}</strong><span role="cell">{project.code}</span><span role="cell">00000</span><span role="cell"><StatusBadge status={project.status} kind="project" /></span><span className="project-list__actions" role="cell"><Link className="button button--secondary button--small" to={`/admin/projects/${project.id}`}></Link></span></div>)}
</div> : <EmptyState title="暂无项目" />}
</section>;
}
function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise<void> }) {
if (!project) return <section className="panel"><EmptyState title="未找到该项目" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/projects"></Link></div></section>;
return <div className="project-maintenance">
<div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects"></Link></div>
<ProjectForm project={project} onSaved={onRefresh} />
</div>;
}
export function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
const [fullscreenError, setFullscreenError] = useState(false);
const enterFullscreen = async (projectId: string) => {
const tile = document.querySelector(`[data-project-screen="${projectId}"]`);
if (!(tile instanceof HTMLElement) || !tile.requestFullscreen) {
setFullscreenError(true);
return;
}
try {
await tile.requestFullscreen();
setFullscreenError(false);
} catch {
setFullscreenError(true);
}
};
return (
<>
{fullscreenError ? <FeedbackBanner tone="warning" title="浏览器未允许全屏"></FeedbackBanner> : null}
{projects.length ? (
<section className="screen-wall" aria-label="项目实时监控墙">
{projects.map((project) => <ProjectScreenTile project={project} onFullscreen={enterFullscreen} key={project.id} />)}
</section>
) : <EmptyState title="暂无可监控项目" />}
</>
);
}
function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto; onFullscreen: (projectId: string) => Promise<void> }) {
const current = project.current_batch && typeof project.current_batch === "object" ? project.current_batch as CallBatchDto : null;
const waitingTicketCount = project.waiting_ticket_count ?? project.waiting_count ?? 0;
const waitingPeopleCount = project.waiting_people_count ?? 0;
const forecasts = forecastRows(current, waitingTicketCount, project.estimated_wait);
const pageCount = Math.max(1, Math.ceil(forecasts.length / 4));
const [page, setPage] = useState(0);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
setPage((value) => Math.min(value, pageCount - 1));
if (pageCount <= 1) return undefined;
const timer = window.setInterval(() => setPage((value) => (value + 1) % pageCount), 6_000);
return () => window.clearInterval(timer);
}, [pageCount]);
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1_000);
return () => window.clearInterval(timer);
}, []);
const visible = forecasts.slice(page * 4, (page + 1) * 4);
const latestTicketNumber = project.latest_ticket_number ?? forecasts.at(-1)?.range.split("").at(-1) ?? "暂无";
const issuedTicketCount = project.issued_ticket_count ?? Number(latestTicketNumber.match(/\d+$/)?.[0] ?? 0);
const experiencedPeople = project.experienced_people ?? project.experienced_people_start ?? 0;
const dateLabel = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long", day: "numeric", weekday: "long" }).format(now);
const timeLabel = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(now);
return (
<article className="screen-tile" data-project-screen={project.id}>
<header className="screen-tile__header">
<div className="screen-tile__clock" aria-label={`当前时间 ${dateLabel} ${timeLabel}`}>
<img className="screen-tile__logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
<div className="screen-tile__clock-copy">
<time dateTime={now.toISOString()}>{timeLabel}</time>
<span>{dateLabel}</span>
</div>
</div>
<div className="screen-tile__project">
<h2>{project.name}</h2>
</div>
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}></button>
</header>
<div className="screen-tile__current">
<strong>{currentBatchNumbers(project.current_batch)}</strong>
<small>{project.current_batch ? "当前叫号" : "等待下一次叫号"}</small>
<div className="screen-tile__current-metrics">
<h3 className="screen-tile__metric" aria-label={`最新取号码:${latestTicketNumber}${latestTicketNumber === "暂无" ? "" : "号"}`}><span></span><strong>{latestTicketNumber}</strong>{latestTicketNumber === "暂无" ? null : <small></small>}</h3>
<div className="screen-tile__metric" aria-label={`累计取号数:${issuedTicketCount}`}><span></span><strong>{issuedTicketCount}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`累计等待人数:${waitingPeopleCount}`}><span></span><strong>{waitingPeopleCount}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`已体验人数:${experiencedPeople}`}><span></span><strong>{experiencedPeople}</strong><small></small></div>
</div>
</div>
<section className="screen-tile__forecast" aria-label={`${project.name}最新取号信息`}>
<div className="screen-tile__forecast-list-heading" aria-hidden="true"><span></span><span></span></div>
{visible.length ? <ol>{visible.map((item) => <li key={item.range}><strong>{item.range}</strong><span>{item.wait}</span></li>)}</ol> : <p></p>}
</section>
</article>
);
}
function AccountManagement({ projects }: { projects: AdminProjectDto[] }) {
const resource = usePollingResource((signal) => api.adminUsers(signal), { intervalMs: 30_000 });
return <>
<section className="panel"><div className="panel__header"><div><h2></h2><p></p></div><Link className="button button--primary" to="/admin/accounts/new"></Link></div>
{resource.loading ? <LoadingState label="正在读取账号" /> : null}
{resource.data?.users.length === 0 ? <EmptyState title="暂无账号" /> : <div className="account-list" role="table" aria-label="账号列表">
<div className="account-list__row account-list__header" role="row"><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span></div>
{resource.data?.users.map((user) => {
const projectNames = projects.filter((project) => user.project_ids.includes(project.id)).map((project) => project.name);
return <div className="account-list__row" role="row" key={user.id}><strong role="cell">{user.username}</strong><span role="cell">{user.role === "ADMIN" ? (user.protected ? "超级管理员" : "管理员") : "员工"}</span><span role="cell"><span className={`account-state account-state--${user.active ? "active" : "inactive"}`}>{user.active ? "已启用" : "已停用"}</span></span><span role="cell">{projectNames.length ? projectNames.join("、") : "未分配"}</span><span role="cell">{user.protected ? <span className="account-state"></span> : <Link className="button button--secondary button--small" to={`/admin/accounts/${user.id}`}></Link>}</span></div>;
})}
</div>}
</section>
</>;
}
function EditAccount({ projects, accountId }: { projects: AdminProjectDto[]; accountId: string }) {
const resource = usePollingResource((signal) => api.adminUsers(signal), { intervalMs: 30_000 });
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<{ role: string; active: boolean; project_ids: string[] }>({ role: "STAFF", active: true, project_ids: [] });
const user = resource.data?.users.find((item) => item.id === accountId);
const toggleProject = (ids: string[], id: string) => ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id];
useEffect(() => {
if (user) setDraft({ role: user.role, active: user.active, project_ids: [...user.project_ids] });
}, [user?.id]);
const dirty = user ? (draft.role !== user.role || draft.active !== user.active || [...draft.project_ids].sort().join("|") !== [...user.project_ids].sort().join("|")) : false;
const resetDraft = () => {
if (!user) return;
setDraft({ role: user.role, active: user.active, project_ids: [...user.project_ids] });
setError(null);
setNotice(null);
};
const save = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!user || !dirty) return;
setError(null);
setNotice(null);
setSaving(true);
try {
await api.updateAdminUser(user.id, draft);
await resource.refresh();
setNotice("账号修改已保存。");
} catch (caught) { setError(caught instanceof ApiError ? caught.message : "账号保存失败"); }
finally { setSaving(false); }
};
if (resource.loading && !resource.data) return <LoadingState label="正在读取账号" />;
if (!user) return <section className="panel"><EmptyState title="未找到该账号" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/accounts"></Link></div></section>;
if (user.protected) return <section className="panel"><EmptyState title="超级管理员账号不可编辑" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/accounts"></Link></div></section>;
return <section className="panel account-maintenance">
<div className="panel__header"><div><h2></h2><p><strong>{user.username}</strong></p></div><Link className="button button--secondary" to="/admin/accounts"></Link></div>
{error ? <FeedbackBanner tone="danger" title={error} /> : null}
<form className="form-stack account-maintenance__form" aria-label="维护账号" onSubmit={save}>
<div className="account-maintenance__basics">
<div className="account-setting-card"><div className="account-setting-card__heading"><strong></strong><small></small></div><div className="account-role-options" role="radiogroup" aria-label="角色">{([['STAFF', '员工'], ['ADMIN', '管理员']] as const).map(([value, label]) => <label key={value} className="account-role-option"><input type="radio" name="account-role" value={value} checked={draft.role === value} onChange={() => setDraft((current) => ({ ...current, role: value }))} disabled={saving} /><span>{label}</span></label>)}</div></div>
<label className="account-toggle"><span><strong></strong><small></small></span><input type="checkbox" checked={draft.active} onChange={(event) => setDraft((current) => ({ ...current, active: event.target.checked }))} disabled={saving} /><i aria-hidden="true" /></label>
</div>
<fieldset className="account-projects">
<legend></legend>
<p>访</p>
<div className="account-projects__grid">
{projects.map((project) => {
const selected = draft.project_ids.includes(project.id);
return <label className={`account-project-option${selected ? " account-project-option--selected" : ""}`} key={project.id}><input type="checkbox" checked={selected} onChange={() => setDraft((current) => ({ ...current, project_ids: toggleProject(current.project_ids, project.id) }))} disabled={saving} /><span>{project.name}</span><small>{selected ? "已授权" : "未授权"}</small></label>;
})}
</div>
</fieldset>
<div className="account-maintenance__actions">
<div className="account-maintenance__feedback" aria-live="polite">{error ? <span className="account-save-state account-save-state--error">{error}</span> : notice ? <span className="account-save-state account-save-state--success">{notice}</span> : dirty ? <span className="account-save-state"></span> : <span className="account-save-state"></span>}</div>
<button className="button button--secondary" type="button" onClick={resetDraft} disabled={!dirty || saving}></button>
<button className="button button--primary" type="submit" disabled={!dirty || saving}>{saving ? "正在保存" : "保存修改"}</button>
</div>
</form>
</section>;
}
function CreateAccount() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const create = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSaving(true);
try {
await api.createAdminUser({ username, password, role: "STAFF", active: true, project_ids: [] });
navigate("/admin/accounts", { replace: true });
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "账号创建失败");
} finally {
setSaving(false);
}
};
return <section className="panel account-create-panel">
<div className="panel__header"><div><h2></h2><p></p></div></div>
{error ? <FeedbackBanner tone="danger" title={error} /> : null}
<form className="form-stack" aria-label="创建账号" onSubmit={create}>
<div className="field-row"><label className="field"><span></span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} disabled={saving} required /></label><label className="field"><span></span><input type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} disabled={saving} required /></label></div>
<div className="account-create-panel__actions"><Link className="button button--secondary" to="/admin/accounts"></Link><button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在创建" : "创建账号"}</button></div>
</form>
</section>;
}
export function AdminPage() {
const location = useLocation();
const resource = usePollingResource((signal) => api.adminOverview(signal), { intervalMs: 5_000 });
const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
const section = location.pathname.startsWith("/admin/projects") ? "projects" : location.pathname.startsWith("/admin/accounts") ? "accounts" : location.pathname.endsWith("/display") ? "display" : "overview";
const resourceId = location.pathname.split("/").at(-1) ?? "";
return (
<AppShell variant="admin">
<div className="admin-workbench">
<AdminNavigation />
<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} />
{section === "overview" ? <OperationsOverview data={data} refreshing={resource.refreshing} onRefresh={resource.refresh} /> : null}
{section === "projects" ? (location.pathname.endsWith("/new") ? <ProjectForm onSaved={resource.refresh} /> : location.pathname === "/admin/projects" ? <ProjectManagement projects={data.projects} /> : <ProjectMaintenance project={data.projects.find((project) => project.id === resourceId)} onRefresh={resource.refresh} />) : null}
{section === "accounts" ? (location.pathname.endsWith("/new") ? <CreateAccount /> : location.pathname === "/admin/accounts" ? <AccountManagement projects={data.projects} /> : <EditAccount projects={data.projects} accountId={location.pathname.split("/").at(-1) ?? ""} />) : null}
{section === "display" ? <DisplayCenter projects={data.projects} /> : null}
</>
) : null}
</section>
</div>
</AppShell>
);
}