165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
import { X } from "lucide-react";
|
|
import { type ReactNode, useEffect, useId, useRef } from "react";
|
|
|
|
interface ConfirmDialogProps {
|
|
open: boolean;
|
|
title: string;
|
|
description: ReactNode;
|
|
confirmLabel: string;
|
|
danger?: boolean;
|
|
busy?: boolean;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, onConfirm, onCancel, children }: ConfirmDialogProps) {
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const panelRef = useDialogLifecycle(open, onCancel, !busy);
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="confirm-backdrop" role="presentation" onClick={() => !busy && onCancel()}>
|
|
<div
|
|
ref={panelRef}
|
|
className="confirm-panel"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
aria-describedby={descriptionId}
|
|
aria-busy={busy}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="panel-header dialog-panel-header">
|
|
<h2 id={titleId}>{title}</h2>
|
|
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onCancel} disabled={busy}>
|
|
<X size={14} />
|
|
<span>关闭</span>
|
|
</button>
|
|
</div>
|
|
<p id={descriptionId}>{description}</p>
|
|
{children}
|
|
<div className="confirm-actions">
|
|
<button type="button" onClick={onCancel} disabled={busy}>
|
|
取消
|
|
</button>
|
|
<button type="button" className={danger ? "confirm-danger" : "confirm-primary"} onClick={onConfirm} disabled={busy}>
|
|
{busy ? "提交中…" : confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ManagementDialogProps {
|
|
open: boolean;
|
|
title: string;
|
|
description?: ReactNode;
|
|
wide?: boolean;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function ManagementDialog({ open, title, description, wide, onClose, children }: ManagementDialogProps) {
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const panelRef = useDialogLifecycle(open, onClose, true);
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="confirm-backdrop management-dialog-backdrop" role="presentation" onClick={onClose}>
|
|
<div
|
|
ref={panelRef}
|
|
className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
aria-describedby={description ? descriptionId : undefined}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="panel-header dialog-panel-header">
|
|
<h2 id={titleId}>{title}</h2>
|
|
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}>
|
|
<X size={14} />
|
|
<span>关闭</span>
|
|
</button>
|
|
</div>
|
|
{description && <p id={descriptionId} className="dialog-description">{description}</p>}
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolean) {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
const panel = panelRef.current;
|
|
const focusTarget = panel?.querySelector<HTMLElement>("button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])");
|
|
focusTarget?.focus();
|
|
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape" && canClose) {
|
|
event.preventDefault();
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
const previousBodyOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
document.body.style.overflow = previousBodyOverflow;
|
|
previousFocus?.focus();
|
|
};
|
|
}, [canClose, onClose, open]);
|
|
|
|
return panelRef;
|
|
}
|
|
|
|
interface UsageMeterProps {
|
|
label: string;
|
|
percent?: number;
|
|
}
|
|
|
|
export function UsageMeter({ label, percent }: UsageMeterProps) {
|
|
const known = typeof percent === "number" && Number.isFinite(percent);
|
|
const clamped = known ? Math.max(0, Math.min(100, percent)) : 0;
|
|
return (
|
|
<div className="usage-meter">
|
|
<span>{label}</span>
|
|
<span className="usage-meter-track" role="img" aria-label={known ? `${label} ${Math.round(clamped)}%` : `${label} 暂无数据`}>
|
|
{known && <span className={`usage-meter-fill${clamped >= 85 ? " usage-high" : ""}`} style={{ width: `${clamped}%` }} />}
|
|
</span>
|
|
<span className="usage-meter-value">{known ? `${Math.round(clamped)}%` : "--"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DiffViewProps {
|
|
lines: Array<{ kind: "same" | "added" | "removed"; text: string }>;
|
|
}
|
|
|
|
export function DiffView({ lines }: DiffViewProps) {
|
|
return (
|
|
<div className="diff-view" role="figure" aria-label="配置变更对比">
|
|
{lines.map((line, index) => (
|
|
<span key={`${index}-${line.text}`} className={`diff-line diff-line-${line.kind}`}>
|
|
<span aria-hidden="true">{line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "}</span>
|
|
<span>{line.text || " "}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|