219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { CheckCircle2, Download, Inbox, Music, Shield, Trash2, X } from "lucide-react";
|
|
|
|
// 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。
|
|
// 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。
|
|
export function OverlayPortal({ children }: { children: ReactNode }) {
|
|
return createPortal(children, document.body);
|
|
}
|
|
|
|
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
|
|
// 多浮层叠开用计数,最后一个关闭才解锁。
|
|
let scrollLockCount = 0;
|
|
export function useBodyScrollLock(active: boolean) {
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
scrollLockCount += 1;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
scrollLockCount -= 1;
|
|
if (scrollLockCount <= 0) { scrollLockCount = 0; document.body.style.overflow = ""; }
|
|
};
|
|
}, [active]);
|
|
}
|
|
|
|
// 浮层进出场过渡 + ESC 统一接管。
|
|
// 进场:挂载后「下一帧」再上 .show,让 CSS 的 scale(.96→1)/translateX 过渡真正跑起来
|
|
// (此前直接挂 .show,首帧即终态 → 没有进场动画)。
|
|
// 退场:先撤 .show 播退场过渡,等过渡时长后再卸载。
|
|
// ESC:open 时监听 Escape → onClose(对齐 MediaLightbox 既有行为)。
|
|
export function useOverlayTransition(open: boolean, onClose?: () => void, durationMs = 260) {
|
|
const [mounted, setMounted] = useState(open);
|
|
const [show, setShow] = useState(false);
|
|
useEffect(() => {
|
|
let raf1 = 0;
|
|
let raf2 = 0;
|
|
let timer = 0;
|
|
if (open) {
|
|
setMounted(true);
|
|
raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setShow(true)); });
|
|
} else {
|
|
setShow(false);
|
|
timer = window.setTimeout(() => setMounted(false), durationMs);
|
|
}
|
|
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); window.clearTimeout(timer); };
|
|
}, [open, durationMs]);
|
|
useEffect(() => {
|
|
if (!open || !onClose) return;
|
|
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [open, onClose]);
|
|
return { mounted, show };
|
|
}
|
|
|
|
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
|
|
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
|
|
export function MediaLightbox({ open, src, kind, name, close, onDownload }: {
|
|
open: boolean;
|
|
src: string;
|
|
kind?: "image" | "video" | "audio";
|
|
name?: string;
|
|
close: () => void;
|
|
onDownload?: () => void;
|
|
}) {
|
|
useBodyScrollLock(open && !!src);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); };
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [open, close]);
|
|
if (!open || !src) return null;
|
|
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
|
return createPortal(
|
|
<div className="np-lightbox show" onClick={close}>
|
|
<div className="lb-actions" onClick={(event) => event.stopPropagation()}>
|
|
{onDownload ? (
|
|
<button className="lb-dl" type="button" aria-label="下载" title="下载" onClick={onDownload}><Download size={16} /></button>
|
|
) : null}
|
|
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
|
|
</div>
|
|
{kind === "video" ? (
|
|
<video
|
|
src={src}
|
|
controls
|
|
autoPlay
|
|
playsInline
|
|
onClick={(event) => event.stopPropagation()}
|
|
style={{ maxWidth: "90vw", maxHeight: "88vh", borderRadius: "var(--r-md)", boxShadow: "0 20px 60px rgba(0,0,0,.5)", background: "#000", cursor: "default" }}
|
|
/>
|
|
) : kind === "audio" ? (
|
|
<div className="lb-audio" onClick={(event) => event.stopPropagation()}>
|
|
<Music aria-hidden="true" />
|
|
<audio src={src} controls autoPlay />
|
|
</div>
|
|
) : (
|
|
<img src={src} alt={name || "预览"} onClick={(event) => event.stopPropagation()} style={{ cursor: "default" }} />
|
|
)}
|
|
{name && <div className="lb-name">{name}</div>}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
|
|
return (
|
|
<div className="setting-row">
|
|
<div><strong>{title}</strong><span>{desc}</span></div>
|
|
{toggle ? <label className="switch"><input type="checkbox" defaultChecked={checked} /><span className="slider" /></label> : <button className="btn btn-sm" type="button">{action}</button>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TeamModal({ open, title, subtitle = "", icon, close, children, footer, dismissable = true }: {
|
|
open: boolean;
|
|
title: string;
|
|
subtitle?: string;
|
|
icon: ReactNode;
|
|
close: () => void;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
/** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */
|
|
dismissable?: boolean;
|
|
}) {
|
|
useBodyScrollLock(open);
|
|
const { mounted, show } = useOverlayTransition(open, close);
|
|
if (!mounted) return null;
|
|
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
|
return createPortal(
|
|
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? close : undefined}>
|
|
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
|
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
|
<div className="modal-h"><div className="ic-m">{icon}</div><div className="ti">{title}<span>{subtitle}</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
|
<div className="modal-b">{children}</div>
|
|
<div className="modal-f"><button className="btn" type="button" onClick={close}>取消</button>{footer || <button className="btn btn-primary" type="button" onClick={close}>保存</button>}</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true, showCancel = true, priority = false }: {
|
|
open: boolean;
|
|
title: string;
|
|
detail: ReactNode;
|
|
confirmText: string;
|
|
subtitle?: string;
|
|
icon?: ReactNode;
|
|
onCancel: () => void;
|
|
onConfirm: () => void | Promise<unknown>;
|
|
/** 是否允许点击遮罩关闭弹窗。默认 true(保持原有行为);传 false 则点遮罩不响应。 */
|
|
dismissable?: boolean;
|
|
/** 强制确认场景可隐藏取消按钮;默认显示,保持现有弹窗行为。 */
|
|
showCancel?: boolean;
|
|
/** 系统级强制提示置于灯箱、抽屉和普通确认框之上。 */
|
|
priority?: boolean;
|
|
}) {
|
|
useBodyScrollLock(open);
|
|
const { mounted, show } = useOverlayTransition(open, onCancel);
|
|
if (!mounted) return null;
|
|
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
|
return createPortal(
|
|
<div className={`modal-bg${priority ? " modal-priority" : ""}${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
|
|
<div className="modal" role="alertdialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
|
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}{subtitle ? <span>{subtitle}</span> : null}</div></div>
|
|
<div className="modal-b">{detail}</div>
|
|
<div className="modal-f">{showCancel ? <button className="btn" type="button" onClick={onCancel}>取消</button> : null}<button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
/* 成功提示弹窗(复用 .modal 设计系统)· 标题 + 说明 + 自定义动作按钮组 */
|
|
export function SuccessModal({ open, title, detail, actions, close }: {
|
|
open: boolean;
|
|
title: string;
|
|
detail: string;
|
|
actions: ReactNode;
|
|
close: () => void;
|
|
}) {
|
|
useBodyScrollLock(open);
|
|
const { mounted, show } = useOverlayTransition(open, close);
|
|
if (!mounted) return null;
|
|
return createPortal(
|
|
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
|
|
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
|
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}</div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
|
<div className="modal-b">{detail}</div>
|
|
<div className="modal-f">{actions}</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function Drawer({ title, open, close, children, className }: { title: string; open: boolean; close: () => void; children: ReactNode; className?: string }) {
|
|
useBodyScrollLock(open);
|
|
const { mounted, show } = useOverlayTransition(open, close);
|
|
if (!mounted) return null;
|
|
return createPortal(
|
|
<>
|
|
<div className={`drawer-bg${show ? " show" : ""}`} onClick={close} />
|
|
<aside className={`drawer${className ? ` ${className}` : ""}${show ? " show" : ""}`}>
|
|
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
|
<div className="drawer-b">{children}</div>
|
|
</aside>
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
|
|
return <div className="empty-state show"><span className="ic-empty"><Inbox size={22} strokeWidth={1.5} aria-hidden="true" /></span><h3>{title}</h3><p>先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
|
|
}
|