feat: add historical data management

This commit is contained in:
wangxuming
2026-07-22 10:18:49 +08:00
parent 66951b4dc3
commit c6044d972c
23 changed files with 3389 additions and 12 deletions

View File

@@ -39,6 +39,7 @@ function AdminNavigation() {
<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>

View File

@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../auth/AuthContext", () => ({
useAuth: () => ({
user: { id: "admin-1", username: "admin", display_name: "管理员", role: "ADMIN" },
projects: [{ id: "project-1", name: "东门观光车", code: "EAST", status: "ENDED" }],
loading: false,
error: null,
login: vi.fn(),
logout: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock("../hooks/usePollingResource", () => ({
usePollingResource: (_loader: unknown, options: { enabled?: boolean; resourceKey?: string }) => {
const key = String(options.resourceKey ?? "");
if (!options.enabled) return { data: undefined, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: null, refresh: vi.fn() };
if (key.includes('"batches"')) return {
data: { items: [], page: 1, page_size: 20, total: 0, from: "2026-07-10", to: "2026-07-16" }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(),
};
if (key.includes('"summary"')) return {
data: {
from: "2026-07-10", to: "2026-07-16",
summary: { total_tickets: 1, total_people: 3, called_tickets: 1, called_people: 3, completed_tickets: 1, completed_people: 3, missed_tickets: 0, canceled_tickets: 0, completion_rate: 1, missed_rate: 0, canceled_rate: 0, average_wait_seconds: 120, max_wait_seconds: 120 },
hourly: [{ hour: 10, issued_tickets: 1, issued_people: 3, called_tickets: 1, called_people: 3 }],
peak_hours: [{ rank: 1, hour: 10, issued_tickets: 1, issued_people: 3, called_tickets: 1, called_people: 3 }],
daily: [{ business_date: "2026-07-16", total_tickets: 1, total_people: 3, called_tickets: 1, completed_tickets: 1, missed_tickets: 0, canceled_tickets: 0, completion_rate: 1, missed_rate: 0, canceled_rate: 0, average_wait_seconds: 120, max_wait_seconds: 120 }],
projects: [{ project_id: "project-1", project_name: "东门观光车", total_tickets: 1, total_people: 3, called_tickets: 1, completed_tickets: 1, missed_tickets: 0, canceled_tickets: 0, completion_rate: 1, missed_rate: 0, canceled_rate: 0, average_wait_seconds: 120, max_wait_seconds: 120 }],
}, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(),
};
return {
data: { items: [{ id: "ticket-1", project_id: "project-1", project_name: "东门观光车", queue_session_id: "session-1", business_date: "2026-07-16", ticket_number: "00012", party_size: 3, status: "COMPLETED", joined_at: "2026-07-16T09:00:00Z", called_at: "2026-07-16T09:02:00Z", wait_seconds: 120, phone_masked: "****8000", personal_data_status: "AVAILABLE" }], page: 1, page_size: 20, total: 1, from: "2026-07-10", to: "2026-07-16" }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(),
};
},
}));
vi.mock("../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api")>();
return { ...actual, api: { adminHistoryExport: vi.fn().mockResolvedValue(new Blob(["号码"])), adminHistoryTicket: vi.fn(), adminHistoryBatch: vi.fn() } };
});
import { HistoryPage } from "./HistoryPage";
afterEach(() => vi.restoreAllMocks());
describe("HistoryPage", () => {
it("默认展示排队历史并支持切换运营统计", () => {
render(<MemoryRouter initialEntries={["/admin/history"]}><HistoryPage /></MemoryRouter>);
expect(screen.getByRole("heading", { name: "历史数据" })).toBeVisible();
expect(screen.getByRole("tab", { name: "排队记录" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("row", { name: /东门观光车/ })).toBeVisible();
expect(screen.getByText("00012")).toBeVisible();
expect(screen.getByText("****8000")).toBeVisible();
fireEvent.click(screen.getByRole("tab", { name: "运营统计" }));
expect(screen.getByRole("tab", { name: "运营统计" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText("历史数据筛选条件")).toHaveClass("history-filters--summary");
expect(screen.getByText("经营复盘")).toBeVisible();
expect(screen.getByText("总人数 3 人")).toBeVisible();
expect(screen.getByText("每日趋势")).toBeVisible();
expect(screen.getByText("完成量")).toBeVisible();
expect(screen.queryByRole("img", { name: "每日取号量与完成量趋势图" })).not.toBeInTheDocument();
expect(screen.getByText("项目对比")).toBeVisible();
expect(screen.getByText("高峰时段")).toBeVisible();
expect(screen.getByText("小时分布")).toBeVisible();
expect(screen.queryByText("需要关注")).not.toBeInTheDocument();
expect(screen.getAllByText("平均等待").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "下钻" }));
expect(screen.getByRole("tab", { name: "排队记录" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("combobox", { name: "项目" })).toHaveValue("project-1");
});
});

View File

@@ -0,0 +1,367 @@
import { useEffect, useMemo, useState } from "react";
import { NavLink } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { ApiError, api } from "../api";
import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { StatusBadge } from "../components/StatusBadge";
import { usePollingResource } from "../hooks/usePollingResource";
import { formatDateTime, formatNumber, formatTicketNumberRange, ticketStatusMeta } from "../lib/format";
import type {
AdminHistoryBatchDto,
AdminHistoryDailyDto,
AdminHistoryHourlyDto,
AdminHistoryPeakHourDto,
AdminHistoryQuery,
AdminHistorySummaryDto,
AdminHistoryTicketDto,
AdminProjectDto,
TicketStatus,
} from "../types";
import { AppShell } from "../components/AppShell";
type HistoryTab = "tickets" | "batches" | "summary";
interface HistoryFilters {
from: string;
to: string;
projectId: string;
status: string;
query: string;
}
function dateValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultHistoryFilters(): HistoryFilters {
const to = new Date();
const from = new Date(to);
from.setDate(from.getDate() - 6);
return { from: dateValue(from), to: dateValue(to), projectId: "", status: "", query: "" };
}
function toQuery(filters: HistoryFilters, page: number, pageSize = 20): AdminHistoryQuery {
return {
from: filters.from,
to: filters.to,
project_id: filters.projectId || undefined,
status: filters.status || undefined,
query: filters.query.trim() || undefined,
page,
page_size: pageSize,
};
}
function formatBusinessDate(value?: string): string {
if (!value) return "暂无日期";
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(date);
}
function formatDuration(seconds?: number | null): string {
if (seconds === null || seconds === undefined) return "暂无";
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return minutes ? `${minutes}${remaining}` : `${remaining}`;
}
function rateLabel(value?: number): string {
return `${((value ?? 0) * 100).toFixed(1)}%`;
}
function batchModeLabel(mode?: string): string {
return mode === "PEOPLE" ? "按人数" : mode === "TICKET" ? "按号码" : mode || "未标注";
}
function batchStatusLabel(status: string): string {
return status === "COMPLETED" ? "已完成" : status === "CALLED" ? "已叫号" : status;
}
function HistoryFiltersBar({
filters,
projects,
tab,
onChange,
onReset,
onExport,
exporting,
}: {
filters: HistoryFilters;
projects: AdminProjectDto[];
tab: HistoryTab;
onChange: (patch: Partial<HistoryFilters>) => void;
onReset: () => void;
onExport: () => void;
exporting: boolean;
}) {
const ticketStatuses = [
["WAITING", "等待中"], ["CALLED", "已叫号"], ["ARRIVED", "已到场"],
["COMPLETED", "已完成"], ["MISSED", "已过号"], ["CANCELED", "已取消"],
];
const batchStatuses = [["CALLED", "已叫号"], ["COMPLETED", "已完成"]];
const statuses = tab === "batches" ? batchStatuses : ticketStatuses;
return <div className={`history-filters history-filters--${tab}`} aria-label="历史数据筛选条件">
<label className="field"><span></span><input type="date" value={filters.from} max={filters.to} onChange={(event) => onChange({ from: event.target.value })} /></label>
<label className="field"><span></span><input type="date" value={filters.to} min={filters.from} onChange={(event) => onChange({ to: event.target.value })} /></label>
<label className="field"><span></span><select value={filters.projectId} onChange={(event) => onChange({ projectId: event.target.value })}><option value=""></option>{projects.map((project) => <option value={project.id} key={project.id}>{project.name}</option>)}</select></label>
{tab !== "summary" ? <label className="field"><span>{tab === "batches" ? "叫号状态" : "号码状态"}</span><select value={filters.status} onChange={(event) => onChange({ status: event.target.value })}><option value=""></option>{statuses.map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label> : null}
{tab !== "summary" ? <label className="field history-filters__query"><span>{tab === "batches" ? "号码或批次" : "号码或手机号"}</span><input value={filters.query} maxLength={80} placeholder="输入号码或手机号即可筛选" onChange={(event) => onChange({ query: event.target.value })} /></label> : null}
<div className="history-filters__actions"><button className="button button--primary button--small" onClick={onReset}></button>{tab === "tickets" ? <button className="button button--secondary button--small" onClick={onExport} disabled={exporting}>{exporting ? "正在导出" : "导出 CSV"}</button> : null}</div>
</div>;
}
function TicketHistoryTable({ data, onSelect, onPageChange }: { data: { items: AdminHistoryTicketDto[]; total: number; page: number; page_size: number }; onSelect: (ticketId: string) => void; onPageChange: (page: number) => void }) {
if (!data.items.length) return <EmptyState title="没有符合条件的排队记录" description="可以调整日期、项目或状态筛选条件。" />;
return <>
<div className="table-scroll history-table-scroll"><table className="history-table"><thead><tr><th>/</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>
{data.items.map((ticket) => <tr key={ticket.id}>
<td><strong>{ticket.project_name}</strong><small>{formatBusinessDate(ticket.business_date)}</small></td>
<td><strong className="history-ticket-number">{ticket.ticket_number}</strong></td>
<td>{ticket.party_size} </td>
<td><StatusBadge status={ticket.status} kind="ticket" /></td>
<td><time>{formatDateTime(ticket.joined_at)}</time></td>
<td><time>{formatDateTime(ticket.called_at)}</time></td>
<td>{formatDuration(ticket.wait_seconds)}</td>
<td><span className="history-personal-cell">{ticket.personal_data_status === "ANONYMIZED" ? "已匿名化" : ticket.phone_masked || "已脱敏"}</span></td>
<td><button className="button button--secondary button--small" onClick={() => onSelect(ticket.id)}></button></td>
</tr>)}
</tbody></table></div>
<HistoryPagination total={data.total} page={data.page} pageSize={data.page_size} onPageChange={onPageChange} />
</>;
}
function BatchHistoryTable({ data, onSelect, onPageChange }: { data: { items: AdminHistoryBatchDto[]; total: number; page: number; page_size: number }; onSelect: (batchId: string) => void; onPageChange: (page: number) => void }) {
if (!data.items.length) return <EmptyState title="没有符合条件的叫号记录" description="可以调整日期、项目或叫号状态筛选条件。" />;
return <>
<div className="table-scroll history-table-scroll"><table className="history-table"><thead><tr><th>/</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>
{data.items.map((batch) => <tr key={batch.id}>
<td><strong>{batch.project_name}</strong><small>{formatBusinessDate(batch.business_date)}</small></td>
<td><strong> {batch.batch_sequence} </strong></td>
<td>{batchModeLabel(batch.call_mode)}</td>
<td>{formatTicketNumberRange(batch.ticket_numbers ?? [])}<small>{batch.ticket_count} </small></td>
<td>{batch.people_count} </td>
<td>{batch.requested_by || "未提供"}</td>
<td><time>{formatDateTime(batch.called_at)}</time></td>
<td><StatusBadge status={batch.status} label={batchStatusLabel(batch.status)} /></td>
<td><button className="button button--secondary button--small" onClick={() => onSelect(batch.id)}></button></td>
</tr>)}
</tbody></table></div>
<HistoryPagination total={data.total} page={data.page} pageSize={data.page_size} onPageChange={onPageChange} />
</>;
}
function HistoryPagination({ total, page, pageSize, onPageChange }: { total: number; page: number; pageSize: number; onPageChange: (page: number) => void }) {
const pages = Math.max(1, Math.ceil(total / pageSize));
return <footer className="history-pagination"><span> {formatNumber(total)} </span><span> {page} / {pages} </span><div className="history-pagination__actions"><button className="button button--secondary button--small" disabled={page <= 1} onClick={() => onPageChange(page - 1)}></button><button className="button button--secondary button--small" disabled={page >= pages} onClick={() => onPageChange(page + 1)}></button></div></footer>;
}
function HistoryTrendChart({ daily }: { daily: AdminHistoryDailyDto[] }) {
if (!daily.length) return <EmptyState title="暂无趋势数据" description="当前日期范围内没有可用于趋势分析的记录。" />;
if (daily.length < 4) return <div className="history-trend-compact" aria-label="每日经营数据摘要">{daily.map((row) => <article key={row.business_date}><time dateTime={row.business_date}>{formatBusinessDate(row.business_date)}</time><strong>{formatNumber(row.total_tickets)}<small></small></strong><dl><div><dt></dt><dd>{rateLabel(row.completion_rate)}</dd></div><div><dt></dt><dd>{formatNumber(row.completed_tickets)}</dd></div></dl></article>)}</div>;
const width = 720;
const height = 240;
const padding = { top: 18, right: 16, bottom: 34, left: 42 };
const maxValue = Math.max(1, ...daily.map((row) => Math.max(row.total_tickets, row.completed_tickets)));
const x = (index: number) => daily.length === 1 ? (width - padding.left - padding.right) / 2 + padding.left : padding.left + (index * (width - padding.left - padding.right)) / (daily.length - 1);
const y = (value: number) => height - padding.bottom - (value / maxValue) * (height - padding.top - padding.bottom);
const issuedPoints = daily.map((row, index) => `${x(index)},${y(row.total_tickets)}`).join(" ");
const completedPoints = daily.map((row, index) => `${x(index)},${y(row.completed_tickets)}`).join(" ");
return <div className="history-trend-chart">
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日取号量与完成量趋势图" preserveAspectRatio="none">
{[0, .5, 1].map((ratio) => <line key={ratio} x1={padding.left} x2={width - padding.right} y1={y(maxValue * ratio)} y2={y(maxValue * ratio)} className="history-trend-gridline" />)}
<polyline points={issuedPoints} className="history-trend-line history-trend-line--issued" />
<polyline points={completedPoints} className="history-trend-line history-trend-line--completed" />
{daily.map((row, index) => <g key={row.business_date}>
<circle cx={x(index)} cy={y(row.total_tickets)} r="4" className="history-trend-dot history-trend-dot--issued" />
<circle cx={x(index)} cy={y(row.completed_tickets)} r="4" className="history-trend-dot history-trend-dot--completed" />
</g>)}
</svg>
<div className="history-trend-labels">{daily.map((row) => <span key={row.business_date}>{formatBusinessDate(row.business_date)}</span>)}</div>
<div className="history-chart-legend"><span><i className="history-chart-legend__dot history-chart-legend__dot--issued" /></span><span><i className="history-chart-legend__dot history-chart-legend__dot--completed" /></span></div>
</div>;
}
function historyPeakHourFallback(hourly: AdminHistoryHourlyDto[]): AdminHistoryPeakHourDto[] {
return [...hourly].sort((left, right) => right.issued_tickets - left.issued_tickets || left.hour - right.hour).slice(0, 3).map((row, index) => ({ ...row, rank: index + 1 }));
}
function SummaryView({ data, onDrillDown }: { data: AdminHistorySummaryDto; onDrillDown: (projectId: string) => void }) {
const summary = data.summary;
const daily = data.daily ?? [];
const projects = data.projects ?? [];
const hourly = data.hourly ?? [];
const peakHours = data.peak_hours?.length ? data.peak_hours : historyPeakHourFallback(hourly);
const maxProjectTickets = Math.max(1, ...projects.map((project) => project.total_tickets));
const maxHourlyTickets = Math.max(1, ...hourly.map((row) => row.issued_tickets));
const peakHour = peakHours[0];
return <div className="history-dashboard">
<header className="history-dashboard__intro">
<div><span className="section-marker" aria-hidden="true" /><div><h2></h2><p></p></div></div>
<span className="history-dashboard__range">{formatBusinessDate(data.from)} {formatBusinessDate(data.to)}</span>
</header>
<section className="history-dashboard-kpis" aria-label="经营复盘核心指标">
<article><span></span><strong>{formatNumber(summary.total_tickets)}</strong><small> {formatNumber(summary.total_people)} </small></article>
<article><span></span><strong>{rateLabel(summary.completion_rate)}</strong><small>{formatNumber(summary.completed_tickets)} </small></article>
<article><span></span><strong>{formatDuration(summary.average_wait_seconds)}</strong><small> {formatDuration(summary.max_wait_seconds)}</small></article>
<article><span></span><strong>{peakHour ? `${String(peakHour.hour).padStart(2, "0")}:00` : "暂无"}</strong><small>{peakHour ? `取号 ${formatNumber(peakHour.issued_tickets)}` : "暂无峰值数据"}</small></article>
<article><span> / </span><strong>{rateLabel(summary.missed_rate)} / {rateLabel(summary.canceled_rate)}</strong><small></small></article>
</section>
<section className="history-dashboard-grid history-dashboard-grid--top">
<article className="history-dashboard-card history-dashboard-card--trend"><div className="history-dashboard-card__header"><div><h3></h3><p></p></div></div><HistoryTrendChart daily={daily} /></article>
</section>
<section className="history-dashboard-card history-dashboard-card--projects"><div className="history-dashboard-card__header"><div><h3></h3><p></p></div></div>{projects.length ? <div className="history-project-list">{projects.slice(0, 6).map((project, index) => <div className="history-project-row" key={project.project_id}><div className="history-project-row__identity"><span>{index + 1}</span><div><strong>{project.project_name}</strong><small>{formatNumber(project.total_people)} · {rateLabel(project.missed_rate)}</small></div></div><div className="history-project-row__bar"><i style={{ width: `${Math.max(4, project.total_tickets / maxProjectTickets * 100)}%` }} /><small>{formatNumber(project.total_tickets)} </small></div><div className="history-project-row__metric"><strong>{rateLabel(project.completion_rate)}</strong><small></small></div><div className="history-project-row__metric"><strong>{formatDuration(project.average_wait_seconds)}</strong><small></small></div><button className="button button--ghost button--small" onClick={() => onDrillDown(project.project_id)}></button></div>)}</div> : <EmptyState title="暂无项目对比" />}</section>
<section className="history-dashboard-grid history-dashboard-grid--bottom">
<article className="history-dashboard-card"><div className="history-dashboard-card__header"><div><h3></h3><p></p></div></div><dl className="history-stat-list"><div><dt></dt><dd>{formatNumber(summary.called_tickets)} · {formatNumber(summary.called_people)} </dd></div><div><dt></dt><dd>{formatNumber(summary.completed_tickets)} · {rateLabel(summary.completion_rate)}</dd></div><div><dt></dt><dd>{formatNumber(summary.missed_tickets)} · {rateLabel(summary.missed_rate)}</dd></div><div><dt></dt><dd>{formatNumber(summary.canceled_tickets)} · {rateLabel(summary.canceled_rate)}</dd></div></dl></article>
<article className="history-dashboard-card history-dashboard-card--rhythm">
<section className="history-dashboard-subsection"><div className="history-dashboard-card__header"><div><h3></h3><p></p></div></div>{peakHours.length ? <ol className="history-peak-list">{peakHours.map((row) => <li key={row.hour}><span className="history-peak-list__rank">{row.rank}</span><div><strong>{String(row.hour).padStart(2, "0")}:00</strong><small> {formatNumber(row.issued_tickets)} · {formatNumber(row.called_tickets)}</small></div><b>{formatNumber(row.issued_people)} </b></li>)}</ol> : <EmptyState title="暂无高峰时段" />}</section>
<section className="history-dashboard-subsection"><div className="history-dashboard-card__header"><div><h3></h3><p></p></div></div>{hourly.length ? <div className="history-hourly-list">{hourly.map((row) => <div className="history-hourly-row" key={row.hour}><span>{String(row.hour).padStart(2, "0")}:00</span><div className="history-hourly-bar"><i style={{ width: `${Math.max(4, row.issued_tickets / maxHourlyTickets * 100)}%` }} /><small> {formatNumber(row.issued_tickets)} · {formatNumber(row.called_tickets)}</small></div></div>)}</div> : <EmptyState title="暂无小时分布" />}</section>
</article>
</section>
</div>;
}
function TicketDetail({ ticket, reveal, loading, error, onReveal, onClose }: { ticket?: AdminHistoryTicketDto; reveal: boolean; loading: boolean; error: ApiError | null; onReveal: () => void; onClose: () => void }) {
const status = ticket ? ticketStatusMeta(ticket.status) : null;
return <div className="history-drawer-backdrop" role="presentation" onClick={onClose}>
<aside className="history-drawer" role="dialog" aria-modal="true" aria-labelledby="history-ticket-detail-title" onClick={(event) => event.stopPropagation()}>
<header className="history-drawer__header">
<div><span className="section-marker" aria-hidden="true" /><h2 id="history-ticket-detail-title">{ticket ? `号码 ${ticket.ticket_number}` : "号码详情"}</h2>{ticket ? <p>{ticket.project_name} · {formatBusinessDate(ticket.business_date)}</p> : null}</div>
<button className="button button--ghost button--small" onClick={onClose}></button>
</header>
<div className="history-drawer__body">
{loading ? <LoadingState label="正在读取号码详情" /> : error ? <FeedbackBanner tone="danger" title="号码详情暂时不可用">{error.message}</FeedbackBanner> : ticket && status ? <>
<div className="history-detail-status"><StatusBadge status={ticket.status} kind="ticket" /><strong>{status.guidance}</strong></div>
<dl className="history-detail-list">
<div><dt></dt><dd>{ticket.party_size} </dd></div>
<div><dt></dt><dd>{formatDateTime(ticket.joined_at, true)}</dd></div>
<div><dt></dt><dd>{formatDateTime(ticket.called_at, true)}</dd></div>
<div><dt></dt><dd>{formatDateTime(ticket.arrived_at, true)}</dd></div>
<div><dt></dt><dd>{formatDateTime(ticket.terminal_at, true)}</dd></div>
<div><dt></dt><dd>{formatDuration(ticket.wait_seconds)}</dd></div>
<div><dt></dt><dd>{ticket.batch_sequence ? `${ticket.batch_sequence}` : "尚未叫号"}</dd></div>
<div><dt></dt><dd>{ticket.created_by || "未提供"}</dd></div>
</dl>
<section className="history-personal-detail">
<h3></h3>
{ticket.personal_data_status === "ANONYMIZED" ? <p></p> : <>
<p>{reveal && ticket.phone ? ticket.phone : ticket.phone_masked || "已脱敏"}{ticket.last_name ? ` · ${ticket.last_name}${ticket.honorific || ""}` : ""}</p>
{reveal ? <small></small> : <button className="button button--secondary button--small" onClick={onReveal}></button>}
</>}
</section>
</> : <EmptyState title="未找到历史号码" />}
</div>
</aside>
</div>;
}
function BatchDetail({ batch, loading, error, onClose }: { batch?: AdminHistoryBatchDto; loading: boolean; error: ApiError | null; onClose: () => void }) {
return <div className="history-drawer-backdrop" role="presentation" onClick={onClose}>
<aside className="history-drawer" role="dialog" aria-modal="true" aria-labelledby="history-batch-detail-title" onClick={(event) => event.stopPropagation()}>
<header className="history-drawer__header">
<div><span className="section-marker" aria-hidden="true" /><h2 id="history-batch-detail-title">{batch ? `${batch.batch_sequence} 次叫号` : "叫号详情"}</h2>{batch ? <p>{batch.project_name} · {formatBusinessDate(batch.business_date)}</p> : null}</div>
<button className="button button--ghost button--small" onClick={onClose}></button>
</header>
<div className="history-drawer__body">
{loading ? <LoadingState label="正在读取叫号详情" /> : error ? <FeedbackBanner tone="danger" title="叫号详情暂时不可用">{error.message}</FeedbackBanner> : batch ? <>
<dl className="history-detail-list">
<div><dt></dt><dd>{batchModeLabel(batch.call_mode)}</dd></div>
<div><dt></dt><dd>{batch.requested_count}</dd></div>
<div><dt></dt><dd>{batch.ticket_count} </dd></div>
<div><dt></dt><dd>{batch.people_count} </dd></div>
<div><dt></dt><dd>{batch.requested_by || "未提供"}</dd></div>
<div><dt></dt><dd>{formatDateTime(batch.called_at, true)}</dd></div>
</dl>
<section className="history-batch-members"><h3></h3>{batch.tickets?.length ? <ol>{batch.tickets.map((ticket) => <li key={ticket.id}><strong>{ticket.ticket_number}</strong><span>{ticket.party_size} </span><StatusBadge status={ticket.status} kind="ticket" /></li>)}</ol> : <EmptyState title="暂无成员数据" />}</section>
</> : <EmptyState title="未找到历史叫号" />}
</div>
</aside>
</div>;
}
export function HistoryPage() {
const { projects } = useAuth();
const [tab, setTab] = useState<HistoryTab>("tickets");
const [filters, setFilters] = useState<HistoryFilters>(() => defaultHistoryFilters());
const [page, setPage] = useState(1);
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const [reveal, setReveal] = useState(false);
const [exporting, setExporting] = useState(false);
const [notice, setNotice] = useState<{ tone: "danger" | "success"; message: string } | null>(null);
const query = useMemo(() => toQuery(filters, page), [filters, page]);
const queryKey = JSON.stringify({ tab, query });
const ticketResource = usePollingResource((signal) => api.adminHistoryTickets(query, signal), { enabled: tab === "tickets", intervalMs: 60_000, resourceKey: queryKey });
const batchResource = usePollingResource((signal) => api.adminHistoryBatches(query, signal), { enabled: tab === "batches", intervalMs: 60_000, resourceKey: queryKey });
const summaryResource = usePollingResource((signal) => api.adminHistorySummary({ ...query, status: undefined, query: undefined }, signal), { enabled: tab === "summary", intervalMs: 60_000, resourceKey: queryKey });
const ticketDetailResource = usePollingResource((signal) => selectedTicketId ? api.adminHistoryTicket(selectedTicketId, reveal, signal) : Promise.reject(new ApiError("未选择历史号码")), { enabled: Boolean(selectedTicketId), intervalMs: 60_000, resourceKey: `${selectedTicketId || "none"}-${reveal ? "reveal" : "masked"}` });
const batchDetailResource = usePollingResource((signal) => selectedBatchId ? api.adminHistoryBatch(selectedBatchId, signal) : Promise.reject(new ApiError("未选择历史叫号")), { enabled: Boolean(selectedBatchId), intervalMs: 60_000, resourceKey: selectedBatchId || "none" });
const activeResource = tab === "tickets" ? ticketResource : tab === "batches" ? batchResource : summaryResource;
useEffect(() => {
setPage(1);
}, [filters.from, filters.to, filters.projectId, filters.status, filters.query, tab]);
const updateFilters = (patch: Partial<HistoryFilters>) => {
setFilters((current) => ({ ...current, ...patch }));
setNotice(null);
};
const resetFilters = () => {
setFilters(defaultHistoryFilters());
setPage(1);
setNotice(null);
};
const changeTab = (next: HistoryTab) => {
setTab(next);
setFilters((current) => ({ ...current, status: "", query: "" }));
setPage(1);
};
const drillDownProject = (projectId: string) => {
setTab("tickets");
setFilters((current) => ({ ...current, projectId, status: "", query: "" }));
setPage(1);
};
const exportTickets = async () => {
setExporting(true);
setNotice(null);
try {
const blob = await api.adminHistoryExport(toQuery(filters, 1, 100));
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `历史排队记录-${filters.from}-${filters.to}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice({ tone: "success", message: "CSV 导出已开始。" });
} catch (caught) {
setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "导出失败,请稍后重试。" });
} finally {
setExporting(false);
}
};
return <AppShell variant="admin">
<div className="admin-workbench">
<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" className="active" aria-current="page"></NavLink><NavLink to="/admin/accounts"></NavLink><NavLink to="/admin/display"></NavLink></nav></aside>
<section className="admin-content history-content" aria-label="历史数据管理">
<section className="panel history-panel"><div className="panel__header"><div><h2></h2><p></p></div></div>
<div className="history-tabs" role="tablist" aria-label="历史数据视图"><button role="tab" aria-selected={tab === "tickets"} className={tab === "tickets" ? "history-tab history-tab--active" : "history-tab"} onClick={() => changeTab("tickets")}></button><button role="tab" aria-selected={tab === "batches"} className={tab === "batches" ? "history-tab history-tab--active" : "history-tab"} onClick={() => changeTab("batches")}></button><button role="tab" aria-selected={tab === "summary"} className={tab === "summary" ? "history-tab history-tab--active" : "history-tab"} onClick={() => changeTab("summary")}></button></div>
<HistoryFiltersBar filters={filters} projects={projects as AdminProjectDto[]} tab={tab} onChange={updateFilters} onReset={resetFilters} onExport={() => void exportTickets()} exporting={exporting} />
{notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "操作完成" : "操作失败"}>{notice.message}</FeedbackBanner> : null}
{activeResource.loading && !activeResource.data ? <LoadingState label="正在读取历史数据" /> : null}
{activeResource.error && !activeResource.data ? <FeedbackBanner tone="danger" title="历史数据暂时不可用" action={<button className="button button--secondary button--small" onClick={activeResource.refresh}></button>}>{activeResource.error.message}</FeedbackBanner> : null}
{activeResource.data ? <FreshnessBanner offline={activeResource.offline} stale={false} timestamp={activeResource.lastClientSuccessAt} refreshing={activeResource.refreshing} errorMessage={activeResource.error?.message} onRetry={activeResource.refresh} /> : null}
{tab === "tickets" && ticketResource.data ? <TicketHistoryTable data={ticketResource.data} onSelect={(id) => { setSelectedTicketId(id); setReveal(false); }} onPageChange={setPage} /> : null}
{tab === "batches" && batchResource.data ? <BatchHistoryTable data={batchResource.data} onSelect={setSelectedBatchId} onPageChange={setPage} /> : null}
{tab === "summary" && summaryResource.data ? <SummaryView data={summaryResource.data} onDrillDown={drillDownProject} /> : null}
</section>
</section>
</div>
{selectedTicketId ? <TicketDetail ticket={ticketDetailResource.data?.ticket} reveal={reveal} loading={ticketDetailResource.loading} error={ticketDetailResource.error} onReveal={() => setReveal(true)} onClose={() => setSelectedTicketId(null)} /> : null}
{selectedBatchId ? <BatchDetail batch={batchDetailResource.data?.batch} loading={batchDetailResource.loading} error={batchDetailResource.error} onClose={() => setSelectedBatchId(null)} /> : null}
</AppShell>;
}