feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export type ModalProps = {
|
||||
@@ -16,7 +16,11 @@ export type ModalProps = {
|
||||
maxWidthClass?: string;
|
||||
/** 面板额外 class(如 max-h / flex) */
|
||||
panelClassName?: string;
|
||||
/** 点遮罩关闭;表单/确认默认 false 防误关 */
|
||||
/**
|
||||
* 点遮罩关闭。按下与抬起都必须落在遮罩上才会关:
|
||||
* 框内拖选后在遮罩松手、或在遮罩按下后拖进弹层松手,都不会关。
|
||||
* 确认/扣费等仍建议关此开关。
|
||||
*/
|
||||
dismissOnOverlay?: boolean;
|
||||
/** busy 时禁用关闭与 ESC */
|
||||
busy?: boolean;
|
||||
@@ -28,7 +32,7 @@ export type ModalProps = {
|
||||
/** 自定义标题区(如带图标);提供时覆盖默认 title 行,仍渲染关闭钮除非 hideClose */
|
||||
header?: ReactNode;
|
||||
hideClose?: boolean;
|
||||
/** 是否响应 ESC;封禁告知等不可关场景设 false */
|
||||
/** 是否响应 ESC;默认 true(按键非误触)。仅封禁告知等必须点确认的场景设 false */
|
||||
closeOnEscape?: boolean;
|
||||
/** 内容区额外 class */
|
||||
bodyClassName?: string;
|
||||
@@ -36,7 +40,8 @@ export type ModalProps = {
|
||||
|
||||
/**
|
||||
* 全站弹层壳:统一遮罩、面板、标题关闭钮与 ESC。
|
||||
* 主 CTA 请用 btn-primary;危险操作用 btn-danger;取消用 btn-line。
|
||||
* 默认:点遮罩不关;开启 dismissOnOverlay 时须按下+抬起都在遮罩上。
|
||||
* ESC / 关闭钮 / 页脚按钮可关。主 CTA 用 btn-primary,危险操作用 btn-danger。
|
||||
*/
|
||||
export default function Modal({
|
||||
open,
|
||||
@@ -57,6 +62,12 @@ export default function Modal({
|
||||
closeOnEscape = true,
|
||||
bodyClassName = "",
|
||||
}: ModalProps) {
|
||||
const overlayPressedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
overlayPressedRef.current = false;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -71,13 +82,33 @@ export default function Modal({
|
||||
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 (
|
||||
<div
|
||||
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`}
|
||||
style={{ background: "rgba(0,0,0,0.55)" }}
|
||||
onClick={() => {
|
||||
if (dismissOnOverlay && !busy) onClose();
|
||||
}}
|
||||
onPointerDown={onOverlayPointerDown}
|
||||
onPointerUp={onOverlayPointerUp}
|
||||
onPointerCancel={onOverlayPointerCancel}
|
||||
role={role}
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
@@ -136,7 +167,7 @@ function CloseButton({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label="关闭"
|
||||
className="w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)] transition-colors disabled:opacity-50"
|
||||
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} />
|
||||
|
||||
Reference in New Issue
Block a user