/** * 会去请求服务端的那种按钮 —— 三面共用(2026-08-24 用户:「点击了都不知道点没点上」)。 * * 一枚按钮按下去之后,人要在**三个时刻**都看得见东西: * · **按下那一刻** —— 键帽下沉(`:active`,纯 CSS,见 `styles/press.css`),证明这一下点着了; * · **在办事的这一段** —— `aria-busy` 亮着,按钮脚下走一道不定长进度条,鼠标也进不来 * (`pointer-events: none`)。这一段从前是**完全静默**的:屏上什么都没变,人只好再点一次; * · **办完那一刻** —— 条收掉,按钮恢复可按。 * * 为什么用 `aria-disabled` + `pointer-events: none`,不用原生 `disabled`: * `disabled` 会把按钮画成灰的 —— 那是「这件事现在做不了」,而它此刻正在**做**。 * 界面不撒谎:在办事就画成在办事,别画成不可用。真正做不了的照旧给 `disabled`。 * * 在办的判断有两条路,谁先来算谁: * · `busy`(常用)—— 把 react-query 的 `isPending` 递进来; * · `onClick` 返回了 Promise —— 自己盯着它落地。 * 两条路之外还有唯一通道上的去重闸(`typed-fetch`),那是**最后一道**: * 就算这里漏了,同一笔请求也不会真的发两遍。 */ import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactNode } from 'react'; type BtnProps = Omit, 'onClick'> & { /** 在办事(通常是 `mutation.isPending`) */ busy?: boolean | undefined; /** 返回 Promise 的话,不必再给 `busy` —— 落地之前一直算在办 */ onClick?: ((e: MouseEvent) => unknown) | undefined; children?: ReactNode; }; function isThenable(v: unknown): v is Promise { return typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function'; } export function Btn({ busy, onClick, disabled, type = 'button', ...rest }: BtnProps) { const [awaiting, setAwaiting] = useState(false); const alive = useRef(true); // 上来先把旗子插回去 —— StrictMode 开发下 effect 走「挂载 → 拆卸 → 再挂载」, // 只在拆卸时落旗的话第二次挂载后它永远是 false,靠 Promise 收尾的按钮会一直亮着「在办事」。 useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const pending = busy === true || awaiting; const fire = useCallback( (e: MouseEvent) => { // 在办事的时候再点一下:什么也不做(不是拒绝,是这一下本来就是余震) if (pending || disabled === true || onClick === undefined) return; const r = onClick(e); if (!isThenable(r)) return; setAwaiting(true); const settle = (): void => { if (alive.current) setAwaiting(false); }; r.then(settle, settle); }, [pending, disabled, onClick], ); return (