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; return String(record.label ?? record.status ?? record.message ?? "已配置"); } return String(value); } function AdminNavigation() { return ( ); } 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 (
运行项目{summary.running_projects}
总等待人数{summary.waiting_people_count ?? 0}
{projects.length ? ( <>
{projects.map((project) => (
{project.name}{deviceStatusLabel(project.device_status)} {project.waiting_people_count ?? 0}人 · {project.waiting_ticket_count ?? project.waiting_count ?? 0} 个号码 {currentBatchLabel(project.current_batch)}
))}
共 {projects.length} 项上一页 1 下一页
) : }
); } 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 }) { 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) => { setDraft((current) => ({ ...current, ...patch })); setNotice(null); }; const submit = async (event: FormEvent) => { 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 (
{!project ?

创建项目

创建项目并同时设置运行规则。

: null}

基础信息

用于识别项目和设置当前运行状态。

叫号规则

设置取号人数范围和员工端单次叫号上限。

支持方式

决定员工端可使用的叫号方式。

单号人数

限定每张号码可登记的人数范围。

批量叫号

按号码个数批量叫号。

批量叫人

按队列实际人数批量叫号。

其他规则

管理游客端展示数据、预计时间和官方提示。