Files
yingqing/core/frontend/src/components/overlays.tsx
T
zycandClaude Fable 5 216a711291 后端生成闸+多项修复;前端全站更新;QA 审计与报告
后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:06:15 +08:00

141 lines
6.0 KiB
TypeScript

import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Music, Shield, X } from "lucide-react";
// 浮层打开时锁住 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]);
}
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
open: boolean;
src: string;
kind?: "image" | "video" | "audio";
name?: string;
close: () => 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:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="np-lightbox show" onClick={close}>
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
{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 }: {
open: boolean;
title: string;
subtitle: string;
icon: ReactNode;
close: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="modal-bg show" onClick={close}>
<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, onCancel, onConfirm }: {
open: boolean;
title: string;
detail: string;
confirmText: string;
onCancel: () => void;
onConfirm: () => void | Promise<unknown>;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="modal-bg show" onClick={onCancel}>
<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"><Shield size={16} /></div><div className="ti">{title}<span>// CONFIRM</span></div></div>
<div className="modal-b">{detail}</div>
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
</div>
</div>,
document.body,
);
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<>
<div className="drawer-bg show" onClick={close} />
<aside className="drawer 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"><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
}