Initial commit
This commit is contained in:
50
web/src/components/AppShell.tsx
Normal file
50
web/src/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
web/src/components/BatchCard.test.tsx
Normal file
38
web/src/components/BatchCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
42
web/src/components/BatchCard.tsx
Normal file
42
web/src/components/BatchCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
web/src/components/Feedback.test.tsx
Normal file
28
web/src/components/Feedback.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
84
web/src/components/Feedback.tsx
Normal file
84
web/src/components/Feedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
web/src/components/StatusBadge.test.tsx
Normal file
10
web/src/components/StatusBadge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
26
web/src/components/StatusBadge.tsx
Normal file
26
web/src/components/StatusBadge.tsx
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user