/** * 提交按钮防连点(纯逻辑 + 全局安装器,供 main.ts 与单测复用)。 * * 为什么需要:各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后 * 立刻恢复可点,用户手快连点就会重复发起。2026-09-17 上架事故里同一店铺被接连 * 提交三次、服务端并存多个同店铺任务,客户端并发打开同一店铺时全部失败。 * * 语义: * - 冷却按**按钮元素**各自计算(WeakMap),点 A 按钮不会影响 B 按钮; * - 从"首次有效点击"起算,被拦的点击不会把冷却越拖越长(否则用户越急越点不开); * - 在**捕获阶段**拦截,抢在 Vue 的 @click 之前,被拦的点击不触发任何提交逻辑。 */ /** 提交按钮的统一类名(17 个工具页的主按钮都用它) */ export const SUBMIT_BUTTON_SELECTOR = '.btn-run' /** 默认冷却时长:够挡住手快连点,又不至于让正常操作感到卡顿 */ export const DEFAULT_SUBMIT_COOLDOWN_MS = 1500 export interface ClickGate { /** 本次点击是否应被拦下(被拦时不会刷新冷却) */ shouldBlock(target: object): boolean /** 解除某个目标的冷却(例如提交失败要允许用户立刻重试) */ reset(target: object): void } export function createClickGate( cooldownMs: number = DEFAULT_SUBMIT_COOLDOWN_MS, now: () => number = () => Date.now(), ): ClickGate { // 非法时长(NaN/0/负数)回落默认值:宁可多挡一下,也不能等同"不设防" const cooldown = Number.isFinite(cooldownMs) && cooldownMs > 0 ? cooldownMs : DEFAULT_SUBMIT_COOLDOWN_MS const lastClickAt = new WeakMap() return { shouldBlock(target: object): boolean { const current = now() const last = lastClickAt.get(target) // 用 undefined(而非 0)表示"从未点击过":哨兵值参与减法会跟时钟起点耦合, // 在 now() 起点较小(测试假时钟/单调时钟)时会误判首次点击为连点 if (last !== undefined && current - last < cooldown) { return true } lastClickAt.set(target, current) return false }, reset(target: object): void { lastClickAt.delete(target) }, } } export interface SubmitGuardOptions { /** 生效的按钮选择器,默认 SUBMIT_BUTTON_SELECTOR */ selector?: string cooldownMs?: number now?: () => number } /** * 全局安装防连点(在 main.ts 调用一次即可,覆盖所有工具页的提交按钮)。 * * @returns 卸载函数(测试与热更新用) */ export function installSubmitGuard(options: SubmitGuardOptions = {}): () => void { const selector = options.selector ?? SUBMIT_BUTTON_SELECTOR const gate = createClickGate(options.cooldownMs, options.now) const handler = (event: Event): void => { const target = event.target as Element | null const button = target && typeof target.closest === 'function' ? target.closest(selector) : null if (!button) { return } if (gate.shouldBlock(button)) { event.stopImmediatePropagation() event.preventDefault() } } document.addEventListener('click', handler, true) return () => document.removeEventListener('click', handler, true) }