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:
2026-09-17 15:41:41 +08:00
parent a89de129ea
commit 2a51006888
4 changed files with 293 additions and 2 deletions
+4
View File
@@ -11,10 +11,14 @@ import router from '@/router'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import { ensureApiSecretsLoaded, loadApiSecrets } from '@/shared/utils/api-secret-store'
import { installUserTokenSync } from '@/shared/auth/user-token-sync.ts'
import { installSubmitGuard } from '@/shared/utils/submit-guard'
// 登录态令牌同步给桌面端 Python(A1/A3):无桥环境静默跳过
installUserTokenSync()
// 提交按钮防连点:捕获阶段拦下同一按钮的连点(各工具页 .btn-run 通用)
installSubmitGuard()
/**
* 数富AI 前端统一入口(SPAURL 无 .html 后缀)
*
+3 -2
View File
@@ -28,8 +28,9 @@ export const CLIENT_CHANGELOG: ClientChangelogEntry[] = [
version: '4.0.28',
date: '2026-09-17',
items: [
'修复上架、跟价等任务「多个任务同时操作同一店铺导致打开店铺失败」的问题,同一店铺改为排队执行',
'紫鸟更新内核期间不再直接报「打开店铺失败」,会等待更新完成并显示等待进度',
'修复多个任务同时操作同一店铺导致打开店铺失败」的问题',
'同一店铺的任务改为排队执行,不会再互相打断',
'紫鸟更新内核期间不再直接报「打开店铺失败」,而是等待完成',
],
},
{
@@ -0,0 +1,88 @@
/**
* 提交按钮防连点(纯逻辑 + 全局安装器,供 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)
}
+198
View File
@@ -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
}
})