Files
crawler-plugin/frontend-vue/src/shared/utils/submit-guard.ts
T
huangzd1997 2a51006888 feat(前端防连点): 工具页提交按钮加全局冷却,并精简 4.0.28 更新日志
各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后立刻恢复可点,
手快连点会重复发起(2026-09-17 上架事故:同一店铺被接连提交三次,服务端并存多个
同店铺任务,客户端并发打开同一店铺时全部失败)。

- 新增 shared/utils/submit-guard.ts:冷却按按钮元素各自计算(WeakMap,点 A 不影响
  B),在捕获阶段拦截、抢在 Vue 的 @click 之前;冷却自首次有效点击起算,被拦的
  点击不会把冷却越拖越长;
- main.ts 全局安装 installSubmitGuard(),覆盖所有 .btn-run 主按钮,页面零改动;
- 13 个单测覆盖边界:不同按钮互不影响、被拦不延长冷却、非法时长回落默认等;
- client-changelog 每条精简到 40 字以内(超出面板显示不下,已有守护测试)。
2026-09-17 15:41:41 +08:00

89 lines
3.2 KiB
TypeScript

/**
* 提交按钮防连点(纯逻辑 + 全局安装器,供 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<object, number>()
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)
}