polish(frontend): 侧栏与徽章可读性,智能悬停提示

降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:54:14 +08:00
parent b9ba8cb8c9
commit 5507552ca5
28 changed files with 482 additions and 268 deletions

View File

@@ -10,6 +10,67 @@ type TipState = {
place: "top" | "bottom";
} | null;
function norm(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
/** 可见文字:跳过 aria-hidden / svg / img,避免把图标标题算进来 */
function getVisibleText(root: Element): string {
const parts: string[] = [];
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent?.replace(/\s+/g, " ").trim();
if (t) parts.push(t);
return;
}
if (!(node instanceof Element)) return;
if (node.getAttribute("aria-hidden") === "true") return;
const tag = node.tagName.toUpperCase();
if (tag === "SVG" || tag === "IMG" || tag === "CANVAS" || tag === "PATH") return;
for (const child of node.childNodes) walk(child);
};
walk(root);
return norm(parts.join(" "));
}
/** 是否存在省略截断(含 line-clamp) */
function isVisuallyTruncated(root: Element): boolean {
const nodes: Element[] = [root, ...Array.from(root.querySelectorAll("*"))];
for (const el of nodes) {
if (!(el instanceof HTMLElement)) continue;
if (el.clientWidth <= 0 && el.clientHeight <= 0) continue;
const st = getComputedStyle(el);
const ellipsis = st.textOverflow === "ellipsis" || el.classList.contains("truncate");
const nowrapHidden =
(st.overflowX === "hidden" || st.overflow === "hidden") &&
(st.whiteSpace === "nowrap" || st.whiteSpace === "pre");
const clampRaw = st.getPropertyValue("-webkit-line-clamp").trim();
const clamped = Boolean(clampRaw) && clampRaw !== "none";
if (ellipsis || nowrapHidden) {
if (el.scrollWidth > el.clientWidth + 1) return true;
}
if (clamped && el.scrollHeight > el.clientHeight + 1) return true;
}
return false;
}
/**
* 仅在以下场景显示气泡:
* 1. 提示文案与可见内容不一致(且提示未完整出现在可见文字中)
* 2. 停留目标无可见文字(图标等)
* 3. 可见内容被省略截断,需要补全
*/
function shouldShowTip(host: Element, tip: string): boolean {
if (isVisuallyTruncated(host)) return true;
const visible = getVisibleText(host);
if (!visible) return true;
const nTip = norm(tip);
const nVis = visible;
if (nTip === nVis) return false;
if (nVis.includes(nTip)) return false;
return true;
}
/**
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
@@ -38,6 +99,16 @@ export default function TipHost() {
});
};
const tryShow = (host: Element) => {
const text = host.getAttribute("data-tip")?.trim();
if (!text || !shouldShowTip(host, text)) {
hide();
return;
}
activeEl.current = host;
placeTip(host, text);
};
const onOver = (e: Event) => {
const t = e.target;
if (!(t instanceof Element)) return;
@@ -46,18 +117,12 @@ export default function TipHost() {
if (activeEl.current) hide();
return;
}
const text = host.getAttribute("data-tip")?.trim();
if (!text) {
hide();
return;
}
activeEl.current = host;
placeTip(host, text);
tryShow(host);
};
const onOut = (e: Event) => {
if (!(e instanceof MouseEvent)) return;
const related = e.relatedTarget;
if (!(e instanceof MouseEvent) && !(e instanceof FocusEvent)) return;
const related = "relatedTarget" in e ? e.relatedTarget : null;
if (
related instanceof Node &&
activeEl.current &&
@@ -70,12 +135,7 @@ export default function TipHost() {
const onScrollOrResize = () => {
if (!activeEl.current) return;
const text = activeEl.current.getAttribute("data-tip")?.trim();
if (!text) {
hide();
return;
}
placeTip(activeEl.current, text);
tryShow(activeEl.current);
};
// mouseover 冒泡,便于 closest;立即显示,无延时