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 字以内(超出面板显示不下,已有守护测试)。
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 提交按钮防连点(shared/utils/submit-guard)行为测试。
|
||||
*
|
||||
* 背景(2026-09-17 上架事故):前端「开始上架 / 启动任务」按钮在提交完成后立刻
|
||||
* 恢复可点,手快连点会重复创建任务;同一店铺并存多个任务后,客户端并发打开
|
||||
* 同一店铺时全部失败。这里把冷却逻辑抽成纯函数以便单测。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
createClickGate,
|
||||
installSubmitGuard,
|
||||
DEFAULT_SUBMIT_COOLDOWN_MS,
|
||||
SUBMIT_BUTTON_SELECTOR,
|
||||
} from '../src/shared/utils/submit-guard.ts'
|
||||
|
||||
function fakeClock(start = 1_000) {
|
||||
let current = start
|
||||
return {
|
||||
now: () => current,
|
||||
advance: (ms: number) => {
|
||||
current += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('默认冷却时长是正数秒级', () => {
|
||||
assert.ok(DEFAULT_SUBMIT_COOLDOWN_MS >= 1000, '冷却至少 1 秒,否则挡不住连点')
|
||||
})
|
||||
|
||||
test('默认选择器覆盖各工具页的主按钮', () => {
|
||||
assert.equal(SUBMIT_BUTTON_SELECTOR, '.btn-run')
|
||||
})
|
||||
|
||||
test('首次点击放行', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1500, clock.now)
|
||||
assert.equal(gate.shouldBlock({}), false)
|
||||
})
|
||||
|
||||
test('冷却期内重复点击被拦截', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1500, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
clock.advance(100)
|
||||
assert.equal(gate.shouldBlock(button), true)
|
||||
clock.advance(1399)
|
||||
assert.equal(gate.shouldBlock(button), true)
|
||||
})
|
||||
|
||||
test('冷却结束时(边界)放行', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1500, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
clock.advance(1500)
|
||||
assert.equal(gate.shouldBlock(button), false, '恰好到达冷却终点应放行')
|
||||
})
|
||||
|
||||
test('不同按钮互不影响', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1500, clock.now)
|
||||
const startButton = {}
|
||||
const matchButton = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(startButton), false)
|
||||
assert.equal(gate.shouldBlock(matchButton), false, '另一个按钮不该被前一个的冷却波及')
|
||||
clock.advance(100)
|
||||
assert.equal(gate.shouldBlock(startButton), true)
|
||||
})
|
||||
|
||||
test('自定义冷却时长生效', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(300, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
clock.advance(299)
|
||||
assert.equal(gate.shouldBlock(button), true)
|
||||
clock.advance(2)
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
})
|
||||
|
||||
test('被拦截的点击不会延长冷却', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1000, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
clock.advance(100)
|
||||
assert.equal(gate.shouldBlock(button), true)
|
||||
}
|
||||
// 从首次点击起算 1000ms 后就该放行,而不是被连点拖长
|
||||
clock.advance(500)
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
})
|
||||
|
||||
test('同一时刻的两次点击只有第一次放行', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1500, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
assert.equal(gate.shouldBlock(button), true, '同一 tick 的第二次点击必须被拦')
|
||||
})
|
||||
|
||||
test('reset 后立即放行', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(1500, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
assert.equal(gate.shouldBlock(button), true)
|
||||
gate.reset(button)
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
})
|
||||
|
||||
test('非法冷却时长回落到默认值', () => {
|
||||
const clock = fakeClock()
|
||||
const gate = createClickGate(Number.NaN, clock.now)
|
||||
const button = {}
|
||||
|
||||
assert.equal(gate.shouldBlock(button), false)
|
||||
clock.advance(DEFAULT_SUBMIT_COOLDOWN_MS - 1)
|
||||
assert.equal(gate.shouldBlock(button), true, 'NaN 应回落为默认冷却而不是立刻放行')
|
||||
})
|
||||
|
||||
test('全局安装:捕获阶段注册且可卸载', () => {
|
||||
const registered: Array<{ type: string; handler: unknown; capture: boolean }> = []
|
||||
const removed: string[] = []
|
||||
const originalDocument = (globalThis as Record<string, unknown>).document
|
||||
;(globalThis as Record<string, unknown>).document = {
|
||||
addEventListener: (type: string, handler: unknown, capture: boolean) => {
|
||||
registered.push({ type, handler, capture })
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(type)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const uninstall = installSubmitGuard({ cooldownMs: 1000, now: () => 0 })
|
||||
assert.equal(registered.length, 1)
|
||||
assert.equal(registered[0].type, 'click')
|
||||
assert.equal(registered[0].capture, true, '必须捕获阶段,否则 Vue 的 @click 已先执行')
|
||||
uninstall()
|
||||
assert.deepEqual(removed, ['click'])
|
||||
} finally {
|
||||
;(globalThis as Record<string, unknown>).document = originalDocument
|
||||
}
|
||||
})
|
||||
|
||||
test('全局安装:只拦 .btn-run 的连点,且第二次点击阻断冒泡', () => {
|
||||
const registered: Array<{ handler: (event: unknown) => void }> = []
|
||||
const originalDocument = (globalThis as Record<string, unknown>).document
|
||||
;(globalThis as Record<string, unknown>).document = {
|
||||
addEventListener: (_type: string, handler: (event: unknown) => void) => {
|
||||
registered.push({ handler })
|
||||
},
|
||||
removeEventListener: () => undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
installSubmitGuard({ cooldownMs: 1000, now: () => 5000 })
|
||||
const handler = registered[0].handler
|
||||
|
||||
const button = { closest: (selector: string) => (selector === '.btn-run' ? button : null) }
|
||||
const elsewhere = { closest: () => null }
|
||||
const makeEvent = (target: unknown) => {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
target,
|
||||
stopImmediatePropagation: () => calls.push('stop'),
|
||||
preventDefault: () => calls.push('prevent'),
|
||||
}
|
||||
}
|
||||
|
||||
const first = makeEvent(button)
|
||||
handler(first)
|
||||
assert.deepEqual(first.calls, [], '首次点击不应被拦')
|
||||
|
||||
const second = makeEvent(button)
|
||||
handler(second)
|
||||
assert.deepEqual(second.calls, ['stop', 'prevent'], '连点必须被拦下')
|
||||
|
||||
const other = makeEvent(elsewhere)
|
||||
handler(other)
|
||||
assert.deepEqual(other.calls, [], '非提交按钮的点击不受影响')
|
||||
} finally {
|
||||
;(globalThis as Record<string, unknown>).document = originalDocument
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user