Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import { useNavigate } from "react-router-dom";
import { useState, type ReactNode } from "react";
import { useAuth } from "../auth/AuthContext";
type AppShellProps = {
children: ReactNode;
variant?: "admin" | "mobile";
hasPrimaryAction?: boolean;
projectName?: string;
};
export function AppShell({ children, variant = "admin", hasPrimaryAction = false, projectName }: AppShellProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [loggingOut, setLoggingOut] = useState(false);
const handleLogout = async () => {
setLoggingOut(true);
try {
await logout();
navigate(variant === "admin" ? "/admin/login" : "/staff/login", { replace: true });
} finally {
setLoggingOut(false);
}
};
return (
<div className={`app-shell app-shell--${variant}${hasPrimaryAction ? "" : " app-shell--no-primary-action"}`}>
<a className="skip-link" href="#main-content"></a>
<header className="app-header">
<div className="brand-lockup" aria-label="景区排队叫号系统">
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>
<span className="brand-lockup__text">
<strong>{projectName || "景区排队叫号系统"}</strong>
{projectName ? <small></small> : null}
</span>
</div>
<div className="account-menu">
<span>
<strong>{user?.display_name || user?.username}</strong>
</span>
<button className="button button--ghost button--small" onClick={handleLogout} disabled={loggingOut}>
{loggingOut ? "正在退出" : "退出"}
</button>
</div>
</header>
<main id="main-content" className="app-main">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BatchCard } from "./BatchCard";
const batch = {
id: "batch-1",
batch_number: 3,
status: "ACTIVE",
called_at: "2026-07-10T09:00:00Z",
tickets: [{
id: "ticket-1",
ticket_number: "00012",
phone: "13800138000",
last_name: "张",
honorific: "先生",
status: "CALLED",
}],
};
describe("BatchCard contact details", () => {
it("shows the full phone and visitor identity on the authenticated staff view", () => {
render(<BatchCard batch={batch} showContactDetails />);
expect(screen.getByText("00012")).toBeVisible();
expect(screen.getByText("本次叫号")).toBeVisible();
expect(screen.queryByText("第 3 批")).not.toBeInTheDocument();
expect(screen.getByText("13800138000")).toBeVisible();
expect(screen.getByText("张先生")).toBeVisible();
expect(screen.getByText("您的号码已叫到")).toBeVisible();
});
it("keeps contact details hidden unless the authenticated view explicitly requests them", () => {
render(<BatchCard batch={batch} />);
expect(screen.queryByText("13800138000")).not.toBeInTheDocument();
expect(screen.queryByText("张先生")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
import type { CallBatchDto } from "../types";
import { batchTicketCount, formatDateTime, formatVisitorName } from "../lib/format";
import { StatusBadge } from "./StatusBadge";
export function BatchCard({
batch,
showContactDetails = false,
}: {
batch: CallBatchDto;
showContactDetails?: boolean;
}) {
return (
<section className="batch-card" aria-labelledby={`batch-${batch.id}`}>
<div className="batch-card__header">
<div>
<h3 id={`batch-${batch.id}`}></h3>
</div>
<StatusBadge status={batch.status} />
</div>
<p className="batch-card__summary">
<strong>{batchTicketCount(batch)} </strong>
<span>{formatDateTime(batch.called_at)}</span>
</p>
<ul className="batch-ticket-list">
{batch.tickets.map((ticket) => (
<li key={ticket.id} className="batch-ticket-row">
<div className="batch-ticket-identity">
<strong className="ticket-number">{ticket.ticket_number}</strong>
{showContactDetails ? (
<span className="ticket-contact-line">
<span><span className="ticket-contact-label"></span> <strong className="phone-value">{ticket.phone || "未返回"}</strong></span>
<span><span className="ticket-contact-label"></span> {formatVisitorName(ticket)}</span>
</span>
) : null}
</div>
<StatusBadge status={ticket.status} kind="ticket" />
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { EmptyState, FreshnessBanner, LoadingState } from "./Feedback";
describe("compact feedback states", () => {
it("keeps loading and empty states to their functional labels", () => {
const { rerender } = render(<LoadingState label="正在读取队列" />);
expect(screen.getByText("正在读取队列")).toBeVisible();
expect(screen.queryByText("请稍候,页面会保留已成功读取的内容。")).not.toBeInTheDocument();
rerender(<EmptyState title="暂无号码" />);
expect(screen.getByText("暂无号码")).toBeVisible();
});
it("hides healthy freshness notes and keeps a short offline recovery state", () => {
const { container, rerender } = render(
<FreshnessBanner offline={false} stale={false} timestamp="2026-07-11T05:00:00Z" />,
);
expect(container).toBeEmptyDOMElement();
rerender(<FreshnessBanner offline={false} stale={false} refreshing timestamp="2026-07-11T05:00:00Z" />);
expect(container).toBeEmptyDOMElement();
rerender(<FreshnessBanner offline stale={false} timestamp="2026-07-11T05:00:00Z" />);
expect(screen.getByText("连接中断")).toBeVisible();
expect(screen.queryByText(/纸质号码|人工广播/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { formatDateTime } from "../lib/format";
export function FeedbackBanner({
tone = "info",
title,
children,
action,
role,
}: {
tone?: "info" | "success" | "warning" | "danger" | "neutral";
title: string;
children?: ReactNode;
action?: ReactNode;
role?: "status" | "alert";
}) {
return (
<div className={`feedback feedback--${tone}`} role={role ?? (tone === "danger" ? "alert" : "status")}>
<span className="feedback__marker" aria-hidden="true" />
<div className="feedback__body">
<strong>{title}</strong>
{children ? <div className="feedback__detail">{children}</div> : null}
</div>
{action ? <div className="feedback__action">{action}</div> : null}
</div>
);
}
export function FreshnessBanner({
offline,
stale,
timestamp,
errorMessage,
onRetry,
}: {
offline: boolean;
stale: boolean;
timestamp?: string | null;
refreshing?: boolean;
errorMessage?: string | null;
onRetry?: () => void;
}) {
if (offline) {
return (
<FeedbackBanner
tone="neutral"
title="连接中断"
action={onRetry ? <button className="button button--secondary button--small" onClick={onRetry}></button> : null}
>
{formatDateTime(timestamp)}
</FeedbackBanner>
);
}
if (stale || errorMessage) {
return (
<FeedbackBanner
tone="warning"
title="更新延迟"
action={onRetry ? <button className="button button--secondary button--small" onClick={onRetry}></button> : null}
>
{errorMessage || `上次更新 ${formatDateTime(timestamp)}`}
</FeedbackBanner>
);
}
return null;
}
export function LoadingState({ label = "正在读取最新数据" }: { label?: string }) {
return (
<div className="state-panel" role="status" aria-live="polite">
<span className="loading-indicator" aria-hidden="true" />
<strong>{label}</strong>
</div>
);
}
export function EmptyState({ title, description }: { title: string; description?: string }) {
return (
<div className="state-panel state-panel--empty">
<strong>{title}</strong>
{description ? <span>{description}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "./StatusBadge";
describe("StatusBadge", () => {
it("renders text in addition to color", () => {
render(<StatusBadge status="PAUSED" kind="project" />);
expect(screen.getByText("已暂停")).toBeVisible();
});
});

View File

@@ -0,0 +1,26 @@
import { projectStatusMeta, ticketStatusMeta } from "../lib/format";
import type { ProjectStatus, TicketStatus } from "../types";
interface StatusBadgeProps {
status: ProjectStatus | TicketStatus;
kind?: "project" | "ticket" | "raw";
label?: string;
tone?: string;
}
export function StatusBadge({ status, kind = "raw", label, tone }: StatusBadgeProps) {
const meta = kind === "project"
? projectStatusMeta(status)
: kind === "ticket"
? ticketStatusMeta(status)
: { label: label ?? status, tone: tone ?? toneForRawStatus(status) };
return <span className={`status-badge status-badge--${meta.tone}`}>{label ?? meta.label}</span>;
}
function toneForRawStatus(status: string): string {
const normalized = status?.toUpperCase();
if (["SUCCESS", "ONLINE", "ACTIVE", "RESOLVED", "SIMULATED_SUCCESS"].includes(normalized)) return "success";
if (["FAILED", "ERROR", "OFFLINE", "SIMULATED_FAILURE"].includes(normalized)) return "danger";
if (["PENDING", "PARTIALLY_RESOLVED", "STALE", "PAUSED"].includes(normalized)) return "warning";
return "neutral";
}