feat: add historical data management
This commit is contained in:
@@ -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>
|
||||
|
||||
77
web/src/pages/HistoryPage.test.tsx
Normal file
77
web/src/pages/HistoryPage.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
367
web/src/pages/HistoryPage.tsx
Normal file
367
web/src/pages/HistoryPage.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user