diff --git a/frontend-vue/src/main.ts b/frontend-vue/src/main.ts index c51a7d9d..bf00bd50 100644 --- a/frontend-vue/src/main.ts +++ b/frontend-vue/src/main.ts @@ -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 前端统一入口(SPA,URL 无 .html 后缀) * diff --git a/frontend-vue/src/shared/client-changelog.ts b/frontend-vue/src/shared/client-changelog.ts index 08a43425..434d99d3 100644 --- a/frontend-vue/src/shared/client-changelog.ts +++ b/frontend-vue/src/shared/client-changelog.ts @@ -28,8 +28,9 @@ export const CLIENT_CHANGELOG: ClientChangelogEntry[] = [ version: '4.0.28', date: '2026-09-17', items: [ - '修复上架、跟价等任务「多个任务同时操作同一个店铺导致打开店铺失败」的问题,同一店铺改为排队执行', - '紫鸟更新内核期间不再直接报「打开店铺失败」,会等待更新完成并显示等待进度', + '修复多个任务同时操作同一店铺导致「打开店铺失败」的问题', + '同一店铺的任务改为排队执行,不会再互相打断', + '紫鸟更新内核期间不再直接报「打开店铺失败」,而是等待完成', ], }, { diff --git a/frontend-vue/src/shared/utils/submit-guard.ts b/frontend-vue/src/shared/utils/submit-guard.ts new file mode 100644 index 00000000..695851b3 --- /dev/null +++ b/frontend-vue/src/shared/utils/submit-guard.ts @@ -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() + + 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) +} diff --git a/frontend-vue/tests/submit-guard.test.ts b/frontend-vue/tests/submit-guard.test.ts new file mode 100644 index 00000000..1bba7981 --- /dev/null +++ b/frontend-vue/tests/submit-guard.test.ts @@ -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).document + ;(globalThis as Record).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).document = originalDocument + } +}) + +test('全局安装:只拦 .btn-run 的连点,且第二次点击阻断冒泡', () => { + const registered: Array<{ handler: (event: unknown) => void }> = [] + const originalDocument = (globalThis as Record).document + ;(globalThis as Record).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).document = originalDocument + } +})