feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板

后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 04:16:32 +08:00
parent fa713bfc3f
commit 5f7193042f
41 changed files with 2297 additions and 873 deletions

View File

@@ -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} />