feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
136
frontend/components/ContextMenu.tsx
Normal file
136
frontend/components/ContextMenu.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user