From 8e9d3a7e49b164b945ada377bc9f5c08c301acb2 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 29 Oct 2026 17:06:25 +0800 Subject: [PATCH] fix(signin): align check-in feedback with reference theme --- assets/js/signin.js | 114 ++++---- inc/helpers/aksrt-header-userbox.php | 4 +- .../user-center-renderers/credits-balance.php | 2 +- .../user-center-renderers/profile-header.php | 1 - src/Credits/Signin.php | 1 - .../captcha-runtime-harness.test.cjs | 4 +- tests/widget-system/signin-runtime.test.cjs | 273 ++++++++++++++++++ 7 files changed, 334 insertions(+), 65 deletions(-) create mode 100644 tests/widget-system/signin-runtime.test.cjs diff --git a/assets/js/signin.js b/assets/js/signin.js index 8682e210..bf62d3e0 100644 --- a/assets/js/signin.js +++ b/assets/js/signin.js @@ -11,6 +11,7 @@ } var signinBusy = false; + var refreshSequence = 0; function api(path, options) { return window.AksrtRequest.request(path, options || {}).then(function (response) { @@ -60,24 +61,17 @@ }); } - function setButtonState(state) { - currentButtons().forEach(function (button) { - var label = button.querySelector('[data-signin-label]'); - var text = state === 'done' - ? button.getAttribute('data-signin-done-label') || '已签到' - : (state === 'busy' ? '…' : button.getAttribute('data-signin-idle-label') || '签到'); - if (label) label.textContent = text; - button.disabled = state !== 'idle'; - button.classList.toggle('is-done', state === 'done'); - }); - } - // 签到成功后,入口按钮就地替换为「已签到」详情弹窗触发链接 // (参考主题:.initiate-checkin → details_link 的替换行为)。 function swapButtonsToDetailsLink() { var url = window.aksrtData && window.aksrtData.signinDetailsUrl; if (!url) return; currentButtons().forEach(function (button) { + var label = button.querySelector('[data-signin-label]'); + if (label) { + label.textContent = button.getAttribute('data-signin-done-label') || '已签到'; + label.removeAttribute('data-signin-label'); + } var link = document.createElement('a'); link.href = 'javascript:;'; link.className = (button.className || '') + ' checkin-details-link'; @@ -88,12 +82,17 @@ link.setAttribute('data-aksrt-modal-mobile-bottom', 'true'); link.setAttribute('data-aksrt-modal-cache', 'false'); link.innerHTML = button.innerHTML; + if (link.classList.contains('uc-header__signin-btn')) link.classList.add('is-done'); button.replaceWith(link); }); } function refresh() { + if (signinBusy) return; + var sequence = ++refreshSequence; api('/signin/today').then(function (result) { + // A status read started before a click must not overwrite its result. + if (signinBusy || sequence !== refreshSequence) return; if (!result.ok) { var message = (result.json && result.json.message) || '签到状态加载失败 (HTTP ' + result.status + ')'; setStatus(message); @@ -104,17 +103,19 @@ var data = result.json; if (!data.enabled) { setStatus('签到功能未启用'); - setButtonState('done'); + currentButtons().forEach(function (button) { + button.disabled = true; + button.setAttribute('disabled', ''); + button.setAttribute('aria-disabled', 'true'); + }); return; } if (data.signed) { setStatusHtml('已签到 ' + data.streak + ' 天 +' + data.reward_today); - setButtonState('done'); swapButtonsToDetailsLink(); return; } setStatusHtml('连续 ' + data.streak + ' 天,+' + data.reward_if_signin); - setButtonState('idle'); }); } @@ -122,62 +123,59 @@ return document.querySelector('[data-signin-captcha]'); } - function doSignin(captchaPayload) { - notyf('正在签到…', 'load', '', 'aksrt-balance-signin-toast'); - api('/signin', { - method: 'POST', - body: JSON.stringify({ captcha: captchaPayload || {} }) - }).then(function (result) { - signinBusy = false; - if (!result.ok) { - notyf((result.json && result.json.message) || '签到失败', 'danger', 5, 'aksrt-balance-signin-toast'); - setButtonState('idle'); - return; - } - - notyf(result.json.message || '签到成功', 'success', 5, 'aksrt-balance-signin-toast'); - document.querySelectorAll('[data-credits-value]').forEach(function (element) { - element.textContent = Number(result.json.balance || 0).toLocaleString(); - element.title = element.textContent; - }); - setButtonState('done'); - swapButtonsToDetailsLink(); - refresh(); - }).finally(function () { - if (window.AKSRTCaptcha) window.AKSRTCaptcha.reset('signin', captchaMount()); - }); - } - freshButtons.forEach(function (button) { button.addEventListener('click', function (event) { event.preventDefault(); if (signinBusy || button.disabled) return; signinBusy = true; - setButtonState('busy'); + ++refreshSequence; + var originalHtml = button.innerHTML; + button.disabled = true; + button.setAttribute('disabled', ''); + button.setAttribute('aria-busy', 'true'); + button.innerHTML = '请稍后'; + + function restoreButton() { + button.innerHTML = originalHtml; + button.disabled = false; + button.removeAttribute('disabled'); + button.removeAttribute('aria-busy'); + } + var captchaRequired = !!window.aksrtData.captchaSignin; var captcha = window.AKSRTCaptcha; var mount = captchaMount(); - if (captchaRequired && (!captcha || !mount)) { - signinBusy = false; - notyf('人机验证未正确加载,请刷新页面后重试', 'danger'); - setButtonState('idle'); - return; - } - if (!captchaRequired) { - doSignin({}); - return; - } - - captcha.run('signin', mount).then(function (payload) { + Promise.resolve().then(function () { + if (captchaRequired && (!captcha || !mount)) { + throw new Error('人机验证未正确加载,请刷新页面后重试'); + } + return captchaRequired ? captcha.run('signin', mount) : {}; + }).then(function (payload) { if (payload === null) throw new Error('已取消验证'); - doSignin(payload); + notyf('正在签到,请稍后...', 'load', '', 'aksrt-balance-signin-toast'); + return api('/signin', { + method: 'POST', + body: JSON.stringify({ captcha: payload }) + }); + }).then(function (result) { + if (!result.ok) throw new Error(result.json.message || '签到失败'); + + restoreButton(); + notyf(result.json.message || '签到成功', 'success', 5, 'aksrt-balance-signin-toast'); + document.querySelectorAll('[data-credits-value]').forEach(function (element) { + element.textContent = Number(result.json.balance || 0).toLocaleString(); + element.title = element.textContent; + }); + setStatusHtml('已签到 ' + result.json.streak + ' 天 +' + result.json.reward); + swapButtonsToDetailsLink(); }).catch(function (error) { + restoreButton(); + notyf((error && error.message) || '签到失败,请稍后重试', 'danger', 5, 'aksrt-balance-signin-toast'); + }).finally(function () { signinBusy = false; - captcha.reset('signin', mount); - notyf((error && error.message) || '人机验证加载中,请稍候再试', 'danger'); - setButtonState('idle'); + if (captcha && mount) captcha.reset('signin', mount); }); }); }); diff --git a/inc/helpers/aksrt-header-userbox.php b/inc/helpers/aksrt-header-userbox.php index e75fbb23..059b0ac2 100644 --- a/inc/helpers/aksrt-header-userbox.php +++ b/inc/helpers/aksrt-header-userbox.php @@ -64,7 +64,7 @@ if (!function_exists('aksrt_header_user_checkin_btn')) : ); } return aksrt_get_user_checkin_details_link( - 'but block c-yellow is-done', + 'but block c-yellow', ' ' . esc_html__('今日已签到', 'aksrt') ); @@ -75,7 +75,6 @@ if (!function_exists('aksrt_header_user_checkin_btn')) : // instead of the UA button stylesheet (font/UI 1:1 with the reference). if ($class !== null) { return '' . '' . '' . esc_html__('签到', 'aksrt') . '' @@ -83,7 +82,6 @@ if (!function_exists('aksrt_header_user_checkin_btn')) : } return '' . ' ' . '' . esc_html__('签到领取今日奖励', 'aksrt') . '' diff --git a/inc/helpers/user-center-renderers/credits-balance.php b/inc/helpers/user-center-renderers/credits-balance.php index 62012d64..d91f86f9 100644 --- a/inc/helpers/user-center-renderers/credits-balance.php +++ b/inc/helpers/user-center-renderers/credits-balance.php @@ -35,7 +35,7 @@ $currency = (string) ($currency_name ?? __('积分', 'aksrt')); ' . esc_html__('已签到', 'aksrt') . ''); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> - diff --git a/inc/helpers/user-center-renderers/profile-header.php b/inc/helpers/user-center-renderers/profile-header.php index 3e2ef0b1..b116e3b1 100644 --- a/inc/helpers/user-center-renderers/profile-header.php +++ b/inc/helpers/user-center-renderers/profile-header.php @@ -85,7 +85,6 @@ extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_ type="button" class="btn btn-sm uc-header__signin-btn" data-signin-btn - data-signin-idle-label="" data-signin-done-label="" > diff --git a/src/Credits/Signin.php b/src/Credits/Signin.php index 2d50d510..35aac51f 100644 --- a/src/Credits/Signin.php +++ b/src/Credits/Signin.php @@ -338,7 +338,6 @@ class Signin { '; } else { $checkin_btn = ''; } diff --git a/tests/widget-system/captcha-runtime-harness.test.cjs b/tests/widget-system/captcha-runtime-harness.test.cjs index 3fa0e673..654b8910 100644 --- a/tests/widget-system/captcha-runtime-harness.test.cjs +++ b/tests/widget-system/captcha-runtime-harness.test.cjs @@ -115,11 +115,13 @@ test('sign-in waits for main before binding and invokes the signin captcha', asy const button = { dataset: {}, disabled: false, + attrs: {}, classList: { toggle() {} }, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, addEventListener(name, handler) { listeners[name] = handler; }, querySelector(selector) { return selector === '[data-signin-label]' ? label : null; }, getAttribute(name) { - if (name === 'data-signin-idle-label') return '签到'; if (name === 'data-signin-done-label') return '已签到'; return ''; }, diff --git a/tests/widget-system/signin-runtime.test.cjs b/tests/widget-system/signin-runtime.test.cjs new file mode 100644 index 00000000..3841ac3a --- /dev/null +++ b/tests/widget-system/signin-runtime.test.cjs @@ -0,0 +1,273 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const root = path.resolve(__dirname, '..', '..'); +const source = fs.readFileSync(path.join(root, 'assets/js/signin.js'), 'utf8'); + +class Element { + constructor(tag, html = '', className = '', labels = {}) { + this.tagName = tag.toUpperCase(); + this._html = html; + this.className = className; + this.dataset = {}; + this.attrs = {}; + this.listeners = {}; + this.replaced = null; + this.disabled = false; + this.title = ''; + this._label = null; + this.labels = labels; + this.classList = { + contains: (name) => this.className.split(/\s+/).includes(name), + add: (name) => { if (!this.classList.contains(name)) this.className = (this.className + ' ' + name).trim(); }, + }; + this.parseLabel(); + } + get innerHTML() { return this._html; } + set innerHTML(value) { this._html = value; this.parseLabel(); } + parseLabel() { + const match = this._html.match(/(.*?)<\/span>/); + if (!match) { this._label = null; return; } + const parent = this; + let value = match[1]; + let hasHook = true; + this._label = { + get textContent() { return value; }, + set textContent(text) { value = text; parent._html = parent._html.replace(/.*?<\/span>/, '' + text + ''); }, + removeAttribute(name) { if (name === 'data-signin-label') { hasHook = false; parent._html = parent._html.replace(' data-signin-label', ''); } }, + }; + } + querySelector(selector) { return selector === '[data-signin-label]' ? this._label : null; } + addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } + getAttribute(name) { return this.attrs[name] ?? this.labels[name] ?? ''; } + setAttribute(name, value) { this.attrs[name] = String(value); } + removeAttribute(name) { delete this.attrs[name]; } + replaceWith(node) { this.replaced = node; } + click() { (this.listeners.click || []).forEach((callback) => callback({ preventDefault() {} })); } +} + +function makeButton(className, label, icon = '', doneLabel = '已签到') { + return new Element('button', icon + '' + label + '', className, { + 'data-signin-done-label': doneLabel, + }); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function setup({ buttons = [makeButton('btn uc-header__signin-btn', '签到')], captchaSignin = false, today = Promise.resolve({ ok: true, status: 200, json: { enabled: true, signed: false, streak: 1, reward_if_signin: 2 } }), signin = Promise.resolve({ ok: true, status: 200, json: { message: '签到成功,积分 +2', balance: 12 } }), captchaRun } = {}) { + const calls = []; + const notices = []; + const balance = { textContent: '10', title: '10' }; + const status = { innerHTML: '', textContent: '' }; + const modals = []; + const listeners = {}; + const mount = { nodeType: 1 }; + const doc = { + querySelectorAll(selector) { + if (selector === '[data-signin-btn]') return buttons.filter((button) => !button.replaced); + if (selector === '[data-credits-value]') return [balance]; + if (selector === '[data-signin-status]') return [status]; + return []; + }, + querySelector(selector) { return selector === '[data-signin-captcha]' ? mount : null; }, + createElement(tag) { return new Element(tag); }, + }; + const jq = { off() { return this; }, on(event, callback) { listeners[event] = callback; return this; } }; + const window = { + aksrtData: { captchaSignin, signinDetailsUrl: '/wp-json/aksrt/v1/signin/details' }, + AksrtModalRuntime: { open: (...args) => modals.push(args), openRemote: (...args) => modals.push(args) }, + AksrtRequest: { request(endpoint, options) { calls.push({ endpoint, options }); return endpoint === '/signin/today' ? today : signin; } }, + }; + const captchaCalls = []; + if (captchaRun) window.AKSRTCaptcha = { + run(scene, scope) { captchaCalls.push(['run', scene, scope]); return captchaRun; }, + reset(scene, scope) { captchaCalls.push(['reset', scene, scope]); }, + }; + vm.runInNewContext(source, { window, document: doc, jQuery: () => jq, notyf: (...args) => notices.push(args), Promise, JSON, Number, tbquire: (_deps, ready) => { ready(); } }); + return { buttons, calls, notices, listeners, window, captchaCalls, mount, balance, status, modals }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test('uses reference loading, success toast, and replaces all entry points with detail links', async () => { + const first = makeButton('img-badge jb-yellow', '签到', ''); + const second = makeButton('btn uc-header__signin-btn', '签到', '', '今日已签到'); + const success = deferred(); + const h = setup({ buttons: [first, second], signin: success.promise }); + await settle(); + const originalOther = second.innerHTML; + first.click(); + assert.equal(first.disabled, true); + assert.equal(first.innerHTML, '请稍后'); + assert.equal(first.getAttribute('aria-busy'), 'true'); + assert.ok(Object.hasOwn(first.attrs, 'disabled')); + assert.equal(second.innerHTML, originalOther, 'non-clicked entries keep their content while the request runs'); + assert.equal(second.disabled, false); + first.click(); + second.click(); + await settle(); + assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 1); + success.resolve({ ok: true, status: 200, json: { message: '签到成功,积分 +2', balance: 12, streak: 2, reward: 2 } }); + await settle(); + assert.deepEqual(h.notices.find((notice) => notice[1] === 'success')?.slice(0, 2), ['签到成功,积分 +2', 'success']); + for (const original of [first, second]) { + assert.equal(original.replaced?.getAttribute('data-aksrt-modal'), 'remote'); + assert.equal(original.replaced?.getAttribute('data-aksrt-modal-url'), '/wp-json/aksrt/v1/signin/details'); + assert.match(original.replaced.innerHTML, /已签到/); + assert.doesNotMatch(original.replaced.innerHTML, /data-signin-label/); + assert.match(original.replaced.className, /checkin-details-link/); + assert.match(original.replaced.innerHTML, /fa-calendar-check/); + assert.equal(Object.hasOwn(original.replaced.attrs, 'disabled'), false); + assert.equal(original.replaced.getAttribute('aria-busy'), ''); + } + assert.doesNotMatch(first.replaced.className, /is-done/); + assert.match(second.replaced.innerHTML, /今日已签到/); + assert.equal(second.replaced.className.includes('uc-header__signin-btn'), true); + assert.equal(h.calls.filter((call) => call.endpoint === '/signin/today').length, 1, 'success does not issue another status request'); + assert.equal(h.modals.length, 0, 'success does not open a modal'); + assert.equal(h.balance.textContent, '12'); + assert.equal(h.status.innerHTML, '已签到 2 天 +2'); +}); + +test('keeps the profile done style on its replacement link', async () => { + const button = makeButton('btn uc-header__signin-btn', '签到'); + const success = deferred(); + const h = setup({ buttons: [button], signin: success.promise }); + await settle(); + button.click(); + success.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1 } }); + await settle(); + assert.match(button.replaced.className, /is-done/); +}); + +test('restores complete button markup after a rejected sign-in response', async () => { + const button = makeButton('img-badge jb-yellow', '签到', ''); + const initial = button.innerHTML; + const h = setup({ buttons: [button], signin: Promise.resolve({ ok: false, status: 500, json: { message: '签到失败' } }) }); + await settle(); + button.click(); + await settle(); + assert.equal(button.innerHTML, initial); + assert.equal(button.disabled, false); + assert.equal(button.getAttribute('aria-busy'), ''); + assert.equal(button.replaced, null); +}); + +test('preserves the existing main/captcha ordering and resets captcha after submission', async () => { + const button = makeButton('btn', '签到'); + const success = deferred(); + const captcha = deferred(); + const h = setup({ buttons: [button], captchaSignin: true, signin: success.promise, captchaRun: captcha.promise }); + await settle(); + button.click(); + await settle(); + assert.deepEqual(h.captchaCalls[0]?.slice(0, 2), ['run', 'signin']); + assert.equal(h.calls.some((call) => call.endpoint === '/signin'), false); + captcha.resolve({ proof: 'ok' }); + await settle(); + assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 1); + success.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1 } }); + await settle(); + assert.deepEqual(h.captchaCalls.map((call) => call[0]), ['run', 'reset']); +}); + +test('allows retry after a rejected request', async () => { + const button = makeButton('btn', '签到'); + const today = deferred(); + const h = setup({ buttons: [button], today: today.promise, signin: Promise.reject(Object.assign(new Error('network down'), { status: 0 })) }); + button.click(); + await settle(); + today.resolve({ ok: true, status: 200, json: { enabled: true, signed: false, streak: 2, reward_if_signin: 3 } }); + await settle(); + assert.equal(button.innerHTML.includes('请稍后'), false); + assert.equal(button.disabled, false); + assert.equal(button.replaced, null); + button.click(); + await settle(); + assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 2); +}); + +test('keeps the loading button when a previous status read completes during submission', async () => { + const button = makeButton('img-badge jb-yellow', '签到'); + const today = deferred(); + const signin = deferred(); + const h = setup({ buttons: [button], today: today.promise, signin: signin.promise }); + button.click(); + await settle(); + today.resolve({ ok: true, status: 200, json: { enabled: true, signed: true, streak: 2, reward_today: 3 } }); + await settle(); + assert.equal(button.replaced, null); + assert.match(button.innerHTML, /loading mr6/); + assert.equal(button.disabled, true); + assert.equal(h.status.innerHTML, ''); + signin.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1, streak: 2, reward: 3 } }); + await settle(); + assert.ok(button.replaced); +}); + +test('ignores an old today response after successful sign-in', async () => { + const button = makeButton('btn', '签到'); + const today = deferred(); + const signin = deferred(); + const h = setup({ buttons: [button], today: today.promise, signin: signin.promise }); + button.click(); + await settle(); + signin.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1, streak: 3, reward: 2 } }); + await settle(); + today.resolve({ ok: true, status: 200, json: { enabled: true, signed: false, streak: 2, reward_if_signin: 3 } }); + await settle(); + assert.ok(button.replaced, 'success replaced the action with the details link'); + assert.match(button.replaced.innerHTML, /已签到/); + assert.equal(h.status.innerHTML, '已签到 3 天 +2'); +}); + +test('leaves a disabled sign-in feature disabled without turning it into a signed state', async () => { + const button = makeButton('btn', '签到'); + const h = setup({ buttons: [button], today: Promise.resolve({ ok: true, status: 200, json: { enabled: false, signed: false } }) }); + await settle(); + assert.equal(button.disabled, true); + assert.equal(button.replaced, null); + assert.doesNotMatch(button.className, /is-done/); + assert.equal(button.innerHTML.includes('已签到'), false); +}); + +test('does not send the request when captcha is absent or cancelled and keeps original markup', async () => { + const missingButton = makeButton('btn', '签到'); + const missing = setup({ buttons: [missingButton], captchaSignin: true }); + missing.window.AKSRTCaptcha = undefined; + // No mount is needed in the harness to verify the missing provider path. + missingButton.click(); + await settle(); + assert.equal(missingButton.disabled, false); + assert.equal(missing.calls.some((call) => call.endpoint === '/signin'), false); + + const cancelledButton = makeButton('btn', '签到'); + const cancelled = setup({ buttons: [cancelledButton], captchaSignin: true, captchaRun: Promise.resolve(null) }); + const original = cancelledButton.innerHTML; + cancelledButton.click(); + await settle(); + assert.equal(cancelledButton.innerHTML, original); + assert.equal(cancelledButton.disabled, false); + assert.equal(cancelled.calls.some((call) => call.endpoint === '/signin'), false); +}); + +test('does not bind the same button twice through repeated auto_fun scans', async () => { + const button = makeButton('btn', '签到'); + const h = setup({ buttons: [button] }); + await settle(); + h.listeners['auto_fun.aksrt-signin'](); + assert.equal(button.listeners.click.length, 1); + button.click(); + await settle(); + assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 1); +});