feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -0,0 +1,136 @@
"use client";
import { useEffect, useRef } from "react";
export type CtxMenuItem = {
key: string;
label: string;
icon?: React.ReactNode;
danger?: boolean;
disabled?: boolean;
onClick: () => void;
};
/**
* 统一右键/长按浮层菜单(飞书式)。
* 定位会钳制在视口内;点击外侧或滚动关闭。
*/
export default function ContextMenu({
x,
y,
items,
onClose,
}: {
x: number;
y: number;
items: CtxMenuItem[];
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = () => onClose();
window.addEventListener("click", close);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [onClose]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let left = x;
let top = y;
if (left + rect.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - rect.width - 8);
}
if (top + rect.height > window.innerHeight - 8) {
top = Math.max(8, window.innerHeight - rect.height - 8);
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}, [x, y, items]);
return (
<div
ref={ref}
className="fixed z-[1200] min-w-[148px] rounded-xl py-1 shadow-lg j13-toast-in"
style={{
left: x,
top: y,
background: "var(--panel)",
border: "1px solid var(--line)",
}}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((it) => (
<button
key={it.key}
type="button"
disabled={it.disabled}
className="w-full flex items-center gap-2.5 px-3.5 py-2 text-[13px] text-left transition-colors disabled:opacity-40"
style={{
color: it.danger ? "var(--danger)" : "var(--ink)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = it.danger
? "var(--danger-soft)"
: "var(--accent-soft)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
onClick={() => {
if (it.disabled) return;
onClose();
it.onClick();
}}
>
{it.icon}
{it.label}
</button>
))}
</div>
);
}
/** 绑定桌面右键 + 移动端长按,打开菜单 */
export function usePressMenu(
openAt: (x: number, y: number) => void
): {
onContextMenu: (e: React.MouseEvent) => void;
onTouchStart: (e: React.TouchEvent) => void;
onTouchEnd: () => void;
onTouchMove: () => void;
} {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = () => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
};
return {
onContextMenu: (e) => {
e.preventDefault();
e.stopPropagation();
openAt(e.clientX, e.clientY);
},
onTouchStart: (e) => {
const t = e.touches[0];
if (!t) return;
clear();
timer.current = setTimeout(() => {
openAt(t.clientX, t.clientY);
}, 480);
},
onTouchEnd: clear,
onTouchMove: clear,
};
}