feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -91,13 +91,14 @@ export default function BoardPicker({
|
||||
}
|
||||
};
|
||||
|
||||
const empty = !loading && !selected;
|
||||
const label = loading
|
||||
? "加载板块…"
|
||||
: selected
|
||||
? selected.name
|
||||
: boards.length === 0
|
||||
? "暂无可用板块"
|
||||
: "选择板块";
|
||||
: "请选择板块";
|
||||
|
||||
const colorIdx = selected
|
||||
? boardColorIndex(selected, selectedIdx)
|
||||
@@ -108,19 +109,28 @@ export default function BoardPicker({
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="j13-board-trigger"
|
||||
className={`j13-board-trigger${empty ? " is-empty" : ""}`}
|
||||
disabled={disabled || loading || boards.length === 0}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-invalid={empty || undefined}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${colorIdx}`} aria-hidden>
|
||||
<span
|
||||
className={`j13-board-trigger-ico${selected ? ` av-${colorIdx}` : " is-placeholder"}`}
|
||||
aria-hidden
|
||||
>
|
||||
{(selected?.name || "板").slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-semibold text-[14px]">
|
||||
{label}
|
||||
<span className="j13-board-trigger-copy">
|
||||
<span className="j13-board-trigger-name">{label}</span>
|
||||
{empty ? (
|
||||
<span className="j13-board-trigger-hint">发布前必须选择</span>
|
||||
) : loading ? (
|
||||
<span className="j13-board-trigger-hint">正在加载</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
|
||||
@@ -101,11 +101,7 @@ export default function ComposeAttachments({
|
||||
const accept = buildAcceptAttr(limits.exts, limits.extLimit);
|
||||
|
||||
const hintLine = limits.extLimit
|
||||
? `单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个;允许 ${
|
||||
limits.exts.length > 8
|
||||
? limits.exts.slice(0, 8).map((e) => `.${e}`).join(" ") + "…"
|
||||
: limits.exts.map((e) => `.${e}`).join(" ") || "(列表为空)"
|
||||
}`
|
||||
? `单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个`
|
||||
: `未限制类型,单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个`;
|
||||
|
||||
/** 仅加入待传列表,不立刻上传 */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
/**
|
||||
* 站点风格确认框:基于 Modal,仅取消/关闭可关,避免点遮罩误关。
|
||||
* 站点风格确认框:基于 Modal。点遮罩不关(需明确取消/关闭);ESC / 取消 / 关闭可关。
|
||||
* danger 主按钮使用 btn-danger,其余用 btn-primary。
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
|
||||
@@ -199,7 +199,7 @@ export default function Header({
|
||||
className="sticky top-0 z-50 border-b"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
background: "color-mix(in srgb, var(--bg) 82%, transparent)",
|
||||
background: "color-mix(in srgb, var(--panel) 86%, transparent)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
@@ -426,7 +426,7 @@ function NavPill({
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
? { background: "transparent", color: "var(--accent)" }
|
||||
: { color: "var(--ink)" }
|
||||
}
|
||||
>
|
||||
@@ -451,7 +451,7 @@ function MobileLink({
|
||||
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
background: active ? "var(--accent-soft)" : "transparent",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -484,7 +484,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
}, [open]);
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="relative hidden lg:block" ref={menuRef}>
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
Pencil,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Columns2,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
Upload,
|
||||
@@ -61,7 +60,7 @@ type MarkdownEditorProps = {
|
||||
allowReplyHide?: boolean;
|
||||
};
|
||||
|
||||
type EditorMode = "split" | "write" | "preview";
|
||||
type EditorMode = "write" | "preview";
|
||||
|
||||
type WrapOpts = {
|
||||
before: string;
|
||||
@@ -126,7 +125,7 @@ export default function MarkdownEditor({
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [mode, setMode] = useState<EditorMode>("split");
|
||||
const [mode, setMode] = useState<EditorMode>("write");
|
||||
const deferredValue = useDeferredValue(value);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
@@ -940,15 +939,6 @@ export default function MarkdownEditor({
|
||||
>
|
||||
<Pencil size={12} /> 编写
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "split"}
|
||||
className={mode === "split" ? "is-on" : ""}
|
||||
onClick={() => setMode("split")}
|
||||
>
|
||||
<Columns2 size={12} /> 双栏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function MobilePostBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleClickNotification(n)}
|
||||
className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
|
||||
className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
|
||||
n.is_read ? "" : "bg-[var(--panel-2)]"
|
||||
}`}
|
||||
style={{
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function PinnedPosts({
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/post/${p.id}`}
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-fade-up"
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] j13-fade-up"
|
||||
style={{
|
||||
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
|
||||
animationDelay: `${i * 35}ms`,
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function PostActions({
|
||||
if (!canManage) return null;
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ export default async function PostRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--accent-soft)] focus-within:bg-[var(--accent-soft)] j13-fade-up ${
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--panel-2)] focus-within:bg-[var(--panel-2)] j13-fade-up ${
|
||||
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
|
||||
}`}
|
||||
style={{
|
||||
|
||||
@@ -17,6 +17,11 @@ function pathIsChat(pathname: string | null) {
|
||||
return pathname === "/chat" || pathname.startsWith("/chat/");
|
||||
}
|
||||
|
||||
function pathIsComposeStudio(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
|
||||
function pathIsPostDetail(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
@@ -75,8 +80,8 @@ export default function SiteChrome({
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 聊天页锁视口、主滚动在对话区,窗口回顶无意义
|
||||
const showBackToTop = !pathIsChat(pathname);
|
||||
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
|
||||
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function StaffUserMenu({
|
||||
};
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
@@ -4,39 +4,52 @@ import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiGetSettings } from "@/lib/api";
|
||||
import { applyAccentTheme } from "@/lib/theme";
|
||||
import { applyCustomCSS } from "@/lib/customCode";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import {
|
||||
VISIBLE_RECONCILE_GAP_MS,
|
||||
onDocumentVisible,
|
||||
} from "@/lib/visibilityReconcile";
|
||||
|
||||
// 站点主题色同步器:
|
||||
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
|
||||
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换
|
||||
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
|
||||
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
|
||||
// 站点外观同步器:
|
||||
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
|
||||
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
|
||||
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
|
||||
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
|
||||
|
||||
export default function ThemeSync({ initialAccent }: { initialAccent: string }) {
|
||||
export default function ThemeSync({
|
||||
initialAccent,
|
||||
initialCustomCSS,
|
||||
}: {
|
||||
initialAccent: string;
|
||||
initialCustomCSS: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
// 当前已应用的已保存色(空串=内置默认);与 SSR 注入的 style 节点保持一致
|
||||
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
|
||||
const cssRef = useRef((initialCustomCSS || "").trim());
|
||||
const lastFetchAtRef = useRef(0);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 外观设置页存在"未保存的本地实时预览",该页面内暂停同步,
|
||||
// 避免 HTTP 校准覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
|
||||
// 外观设置页存在未保存的本地实时预览,暂停同步避免覆盖草稿
|
||||
if (pathname?.startsWith("/admin/appearance")) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const apply = (next: string) => {
|
||||
const applyAccent = (next: string) => {
|
||||
if (cancelled || next === currentRef.current) return;
|
||||
currentRef.current = next;
|
||||
applyAccentTheme(next || null);
|
||||
};
|
||||
|
||||
const applyCSS = (next: string) => {
|
||||
if (cancelled) return;
|
||||
const text = (next || "").trim();
|
||||
if (text === cssRef.current) return;
|
||||
cssRef.current = text;
|
||||
applyCustomCSS(text);
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
if (inFlightRef.current) return;
|
||||
const now = Date.now();
|
||||
@@ -44,25 +57,24 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
|
||||
lastFetchAtRef.current = now;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
// no-store:必须拿到管理员刚保存的最新值,不能走浏览器缓存
|
||||
const { accent } = await apiGetSettings();
|
||||
const settings = await apiGetSettings();
|
||||
if (cancelled) return;
|
||||
apply((accent || "").trim().toLowerCase());
|
||||
applyAccent((settings.accent || "").trim().toLowerCase());
|
||||
applyCSS(settings.custom_css || "");
|
||||
} catch {
|
||||
// 主题同步失败静默处理,下次回前台再试,不打扰用户
|
||||
// 同步失败静默,下次回前台再试
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket 推送优先:管理员保存即换肤;回前台 HTTP 校准兜底
|
||||
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (data) apply((data.accent || "").trim().toLowerCase());
|
||||
if (!data) return;
|
||||
applyAccent((data.accent || "").trim().toLowerCase());
|
||||
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
|
||||
});
|
||||
|
||||
// 仅 visibility:window focus 与 visibility 常连发,统一走限频即可
|
||||
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
|
||||
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
|
||||
void sync();
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function AdminSearchField({
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="清除搜索"
|
||||
className="w-7 h-7 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
|
||||
className="w-7 h-7 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<X size={15} />
|
||||
|
||||
@@ -263,7 +263,7 @@ function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void })
|
||||
}, [open]);
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
@@ -45,14 +45,16 @@ export function AdminChartCard({
|
||||
hint,
|
||||
children,
|
||||
actions,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-4 sm:p-5 flex flex-col min-h-[260px]">
|
||||
<div className={`panel p-4 sm:p-5 flex flex-col ${className}`.trim()}>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
@@ -66,7 +68,7 @@ export function AdminChartCard({
|
||||
</div>
|
||||
{/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */}
|
||||
<div
|
||||
className="flex-1 min-h-[180px] w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
|
||||
className="w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
|
||||
onMouseDownCapture={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
@@ -144,15 +146,17 @@ export function SingleAreaChart({
|
||||
dataKey,
|
||||
name,
|
||||
color = "var(--accent)",
|
||||
height = 200,
|
||||
}: {
|
||||
data: { date: string; [k: string]: string | number }[];
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
height?: number;
|
||||
}) {
|
||||
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
@@ -183,8 +187,10 @@ export function NamedBarChart({
|
||||
layout?: "vertical" | "horizontal";
|
||||
}) {
|
||||
if (layout === "horizontal") {
|
||||
// 固定像素高度,避免 overflow 滚动条 ↔ ResponsiveContainer 互相触发闪烁
|
||||
const chartH = Math.max(180, Math.min(320, data.length * 28));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 28)}>
|
||||
<ResponsiveContainer width="100%" height={chartH}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 12, left: 8, bottom: 4 }} accessibilityLayer={false}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} allowDecimals={false} />
|
||||
|
||||
@@ -296,7 +296,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
>
|
||||
<Avatar name={name} src={u.avatar} size={38} />
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user