114 lines
3.2 KiB
TypeScript
114 lines
3.2 KiB
TypeScript
import { AlertTriangle, CheckCircle2, Copy, Loader2, MoonStar, Sparkles, XCircle } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
|
|
import { cx } from "../utils/classes";
|
|
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
|
|
|
interface EmptyStateProps {
|
|
title: string;
|
|
description: string;
|
|
actionLabel?: string;
|
|
onAction?: () => void;
|
|
icon?: ReactNode;
|
|
}
|
|
|
|
export function EmptyState({ title, description, actionLabel, onAction, icon }: EmptyStateProps) {
|
|
return (
|
|
<div className="state-view state-empty" role="status">
|
|
<span className="state-icon" aria-hidden="true">
|
|
{icon ?? <MoonStar size={26} />}
|
|
</span>
|
|
<strong>{title}</strong>
|
|
<p>{description}</p>
|
|
{actionLabel && onAction && (
|
|
<button type="button" className="state-action" onClick={onAction}>
|
|
<Sparkles size={14} />
|
|
<span>{actionLabel}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface LoadingStateProps {
|
|
label: string;
|
|
compact?: boolean;
|
|
}
|
|
|
|
export function LoadingState({ label, compact }: LoadingStateProps) {
|
|
return (
|
|
<div className={cx("state-view state-loading", compact && "state-compact")} role="status" aria-live="polite">
|
|
<Loader2 size={compact ? 16 : 22} className="state-spinner" aria-hidden="true" />
|
|
<span>{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ErrorStateProps {
|
|
title: string;
|
|
reason?: string;
|
|
diagnosticId?: string;
|
|
onRetry?: () => void;
|
|
compact?: boolean;
|
|
}
|
|
|
|
export function ErrorState({ title, reason, diagnosticId, onRetry, compact }: ErrorStateProps) {
|
|
return (
|
|
<div className={cx("state-view state-error", compact && "state-compact")} role="alert">
|
|
<span className="state-icon" aria-hidden="true">
|
|
<AlertTriangle size={compact ? 16 : 24} />
|
|
</span>
|
|
<strong>{title}</strong>
|
|
{reason && <p>{safeDiagnosticText(reason)}</p>}
|
|
{diagnosticId && <DiagnosticSummary diagnosticId={diagnosticId} />}
|
|
{onRetry && (
|
|
<button type="button" className="state-action" onClick={onRetry}>
|
|
<Sparkles size={14} />
|
|
<span>重试</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ResultBadgeProps {
|
|
status: "pending" | "succeeded" | "failed";
|
|
label: string;
|
|
}
|
|
|
|
export function ResultBadge({ status, label }: ResultBadgeProps) {
|
|
const icon =
|
|
status === "pending" ? <Loader2 size={13} className="state-spinner" /> : status === "succeeded" ? <CheckCircle2 size={13} /> : <XCircle size={13} />;
|
|
return (
|
|
<span className={cx("result-badge", `result-badge-${status}`)}>
|
|
{icon}
|
|
<span>{status === "failed" ? safeDiagnosticText(label) : label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface DiagnosticSummaryProps {
|
|
diagnosticId: string;
|
|
detail?: string;
|
|
}
|
|
|
|
export function DiagnosticSummary({ diagnosticId, detail }: DiagnosticSummaryProps) {
|
|
const summary = detail ? `${diagnosticId} ${detail}` : diagnosticId;
|
|
return (
|
|
<span className="diagnostic-summary">
|
|
<code>{diagnosticId}</code>
|
|
<button
|
|
type="button"
|
|
className="diagnostic-copy"
|
|
title="复制诊断信息"
|
|
onClick={() => {
|
|
void navigator.clipboard?.writeText(summary);
|
|
}}
|
|
>
|
|
<Copy size={12} />
|
|
<span>复制</span>
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|