feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
135
frontend/components/TipHost.tsx
Normal file
135
frontend/components/TipHost.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type TipState = {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
place: "top" | "bottom";
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
|
||||
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
|
||||
*/
|
||||
export default function TipHost() {
|
||||
const [tip, setTip] = useState<TipState>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const activeEl = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
activeEl.current = null;
|
||||
setTip(null);
|
||||
};
|
||||
|
||||
const placeTip = (el: Element, text: string) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const preferBottom = rect.top < 48;
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = preferBottom ? rect.bottom + 8 : rect.top - 8;
|
||||
setTip({
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
place: preferBottom ? "bottom" : "top",
|
||||
});
|
||||
};
|
||||
|
||||
const onOver = (e: Event) => {
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
const host = t.closest("[data-tip]");
|
||||
if (!host) {
|
||||
if (activeEl.current) hide();
|
||||
return;
|
||||
}
|
||||
const text = host.getAttribute("data-tip")?.trim();
|
||||
if (!text) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
activeEl.current = host;
|
||||
placeTip(host, text);
|
||||
};
|
||||
|
||||
const onOut = (e: Event) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
const related = e.relatedTarget;
|
||||
if (
|
||||
related instanceof Node &&
|
||||
activeEl.current &&
|
||||
activeEl.current.contains(related)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hide();
|
||||
};
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
if (!activeEl.current) return;
|
||||
const text = activeEl.current.getAttribute("data-tip")?.trim();
|
||||
if (!text) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
placeTip(activeEl.current, text);
|
||||
};
|
||||
|
||||
// mouseover 冒泡,便于 closest;立即显示,无延时
|
||||
document.addEventListener("mouseover", onOver);
|
||||
document.addEventListener("mouseout", onOut);
|
||||
document.addEventListener("focusin", onOver);
|
||||
document.addEventListener("focusout", onOut);
|
||||
window.addEventListener("scroll", onScrollOrResize, true);
|
||||
window.addEventListener("resize", onScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseover", onOver);
|
||||
document.removeEventListener("mouseout", onOut);
|
||||
document.removeEventListener("focusin", onOver);
|
||||
document.removeEventListener("focusout", onOut);
|
||||
window.removeEventListener("scroll", onScrollOrResize, true);
|
||||
window.removeEventListener("resize", onScrollOrResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 测量后微调,避免贴边裁切
|
||||
useEffect(() => {
|
||||
if (!tip || !tipRef.current) return;
|
||||
const el = tipRef.current;
|
||||
const w = el.offsetWidth;
|
||||
const pad = 10;
|
||||
let left = tip.x;
|
||||
const half = w / 2;
|
||||
if (left - half < pad) left = pad + half;
|
||||
if (left + half > window.innerWidth - pad) {
|
||||
left = window.innerWidth - pad - half;
|
||||
}
|
||||
el.style.left = `${left}px`;
|
||||
}, [tip]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
className="j13-tip-bubble"
|
||||
data-place={tip.place}
|
||||
style={{
|
||||
left: tip.x,
|
||||
top: tip.y,
|
||||
transform:
|
||||
tip.place === "top"
|
||||
? "translate(-50%, -100%)"
|
||||
: "translate(-50%, 0)",
|
||||
}}
|
||||
>
|
||||
{tip.text}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user