2a51006888
各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后立刻恢复可点, 手快连点会重复发起(2026-09-17 上架事故:同一店铺被接连提交三次,服务端并存多个 同店铺任务,客户端并发打开同一店铺时全部失败)。 - 新增 shared/utils/submit-guard.ts:冷却按按钮元素各自计算(WeakMap,点 A 不影响 B),在捕获阶段拦截、抢在 Vue 的 @click 之前;冷却自首次有效点击起算,被拦的 点击不会把冷却越拖越长; - main.ts 全局安装 installSubmitGuard(),覆盖所有 .btn-run 主按钮,页面零改动; - 13 个单测覆盖边界:不同按钮互不影响、被拦不延长冷却、非法时长回落默认等; - client-changelog 每条精简到 40 字以内(超出面板显示不下,已有守护测试)。
89 lines
3.2 KiB
TypeScript
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)
|
|
}
|