Files
jiang13-bbs/frontend/components/Modal.tsx
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

179 lines
5.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
export type ModalProps = {
open: boolean;
title: ReactNode;
onClose: () => void;
children: ReactNode;
/** 底部操作区(取消/主按钮等) */
footer?: ReactNode;
/** 标题旁辅助说明 */
description?: ReactNode;
/** 面板最大宽度 class,默认 max-w-md */
maxWidthClass?: string;
/** 面板额外 class(如 max-h / flex) */
panelClassName?: string;
/**
* 点遮罩关闭。按下与抬起都必须落在遮罩上才会关:
* 框内拖选后在遮罩松手、或在遮罩按下后拖进弹层松手,都不会关。
* 确认/扣费等仍建议关此开关。
*/
dismissOnOverlay?: boolean;
/** busy 时禁用关闭与 ESC */
busy?: boolean;
/** 叠层高度,确认框用 1100,普通 1000 */
zIndexClass?: string;
/** 无障碍:dialog | alertdialog */
role?: "dialog" | "alertdialog";
ariaLabel?: string;
/** 自定义标题区(如带图标);提供时覆盖默认 title 行,仍渲染关闭钮除非 hideClose */
header?: ReactNode;
hideClose?: boolean;
/** 是否响应 ESC;默认 true(按键非误触)。仅封禁告知等必须点确认的场景设 false */
closeOnEscape?: boolean;
/** 内容区额外 class */
bodyClassName?: string;
};
/**
* 全站弹层壳:统一遮罩、面板、标题关闭钮与 ESC。
* 默认:点遮罩不关;开启 dismissOnOverlay 时须按下+抬起都在遮罩上。
* ESC / 关闭钮 / 页脚按钮可关。主 CTA 用 btn-primary,危险操作用 btn-danger。
*/
export default function Modal({
open,
title,
onClose,
children,
footer,
description,
maxWidthClass = "max-w-md",
panelClassName = "",
dismissOnOverlay = false,
busy = false,
zIndexClass = "z-[1000]",
role = "dialog",
ariaLabel,
header,
hideClose = false,
closeOnEscape = true,
bodyClassName = "",
}: ModalProps) {
const overlayPressedRef = useRef(false);
useEffect(() => {
overlayPressedRef.current = false;
}, [open]);
useEffect(() => {
if (!open || !closeOnEscape) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onClose, closeOnEscape]);
if (!open) return null;
const label =
ariaLabel || (typeof title === "string" ? title : undefined);
const onOverlayPointerDown = (e: PointerEvent<HTMLDivElement>) => {
overlayPressedRef.current =
e.button === 0 && e.target === e.currentTarget;
};
const onOverlayPointerUp = (e: PointerEvent<HTMLDivElement>) => {
const pressedOnOverlay = overlayPressedRef.current;
overlayPressedRef.current = false;
if (!dismissOnOverlay || busy) return;
if (e.button !== 0) return;
// 用 pointerup 的真实落点,不用 click:mousedown 在遮罩、mouseup 在面板时
// click 会合成到共同祖先(遮罩)上,造成拖进弹层仍关闭。
if (!pressedOnOverlay || e.target !== e.currentTarget) return;
onClose();
};
const onOverlayPointerCancel = () => {
overlayPressedRef.current = false;
};
return createPortal(
<div
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`}
style={{ background: "rgba(0,0,0,0.55)" }}
onPointerDown={onOverlayPointerDown}
onPointerUp={onOverlayPointerUp}
onPointerCancel={onOverlayPointerCancel}
role={role}
aria-modal="true"
aria-label={label}
>
<div
className={`w-full ${maxWidthClass} rounded-2xl j13-toast-in ${panelClassName || "p-6"}`}
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
onClick={(e) => e.stopPropagation()}
>
{header ? (
<div className="flex items-start justify-between gap-3 mb-4">
<div className="min-w-0 flex-1">{header}</div>
{!hideClose && (
<CloseButton onClick={onClose} disabled={busy} />
)}
</div>
) : (
<div className="flex items-start justify-between gap-3 mb-4">
<div className="min-w-0 flex-1">
<h2
className="text-[16px] sm:text-[18px] font-extrabold tracking-tight leading-snug"
style={{ color: "var(--ink)" }}
>
{title}
</h2>
{description ? (
<div className="mt-1 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{description}
</div>
) : null}
</div>
{!hideClose && <CloseButton onClick={onClose} disabled={busy} />}
</div>
)}
<div className={bodyClassName}>{children}</div>
{footer ? (
<div className="flex items-center justify-end gap-2 mt-5">{footer}</div>
) : null}
</div>
</div>,
document.body
);
}
function CloseButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label="关闭"
className="w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
>
<X size={15} />
</button>
);
}