diff --git a/Update.json b/Update.json index 362302d3..891b4b74 100644 --- a/Update.json +++ b/Update.json @@ -3861,6 +3861,17 @@ } ], "Notes": "
修复浏览器过滤规则隐藏导航栏占位元素、导致页面顶部内容被遮挡的问题。
" + }, + "3.8.5": { + "UpdateDate": 1791201776409, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1045, + "Description": "fix: restore admin add-tag button for users without tags" + } + ], + "Notes": "修复用户没有标签时个人中心误显示加载错误和重试按钮的问题,恢复管理员的添加标签入口。修改账号页面在没有标签时隐藏标签编辑,避免提交无效修改;实际请求失败时仍可重试。
" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index 5f659d6e..b1cfacce 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.4 +// @version 3.8.5 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen @@ -2310,6 +2310,12 @@ function GetProfileSolvedProblems(root = document) { return [...problems]; } +function IsMissingUserBadge(response) { + // Missing rows use an error message rather than a separate status code. + // Match the missing-tag phrase; authentication and other failures also have no data. + return response?.Success === false && typeof response.Message === "string" && /标签\s*(?:在数据库中\s*)?不存在/.test(response.Message); +} + function InitializeProfileBadge(container, userID, isAdmin) { let badge = document.createElement("span"); badge.className = "badge me-2"; @@ -2341,8 +2347,9 @@ function InitializeProfileBadge(container, userID, isAdmin) { retry.disabled = true; controls.replaceChildren(); RequestAPI("GetBadge", {UserID: userID}, response => { - if (!response?.Success) { Fail(response?.Message || "服务器响应异常,请重试"); return; } - let data = response.Data ?? {}; + let missing = IsMissingUserBadge(response); + if (!response?.Success && !missing) { Fail(response?.Message || "服务器响应异常,请重试"); return; } + let data = missing ? {} : response.Data ?? {}; let content = String(data.Content ?? ""); badge.textContent = content; badge.hidden = content === ""; @@ -2635,23 +2642,24 @@ function InitializeAccountFeatures(authenticated) { function LoadAccountBadge(content, background, color, onLoad, onError) { RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => { - if (!response?.Success) { + let missing = IsMissingUserBadge(response); + if (!response?.Success && !missing) { onError(response?.Message || "服务器响应异常,请重试"); return; } - let badge = response.Data ?? {}; + let badge = missing ? {} : response.Data ?? {}; content.value = String(badge.Content ?? ""); // Color inputs cannot represent an empty value. Use explicit defaults // for missing/invalid colors rather than the browser's silent fallback. background.value = /^#[0-9a-f]{6}$/i.test(badge.BackgroundColor) ? badge.BackgroundColor : "#000000"; color.value = /^#[0-9a-f]{6}$/i.test(badge.Color) ? badge.Color : "#ffffff"; - onLoad(); + onLoad(!missing); }, onError); } // Unloaded fields must never be saved as a blank replacement for an existing badge. function InitializeAccountBadgeLoading(content, background, color, status, onReady = () => {}) { - let state = {loaded: false}; + let state = {loaded: false, exists: false}; let retry = document.createElement("button"); retry.type = "button"; retry.className = "btn btn-outline-secondary mt-2"; @@ -2659,17 +2667,19 @@ function InitializeAccountBadgeLoading(content, background, color, status, onRea status.insertAdjacentElement("afterend", retry); let Load = () => { state.loaded = false; + state.exists = false; [content, background, color].forEach(input => { input.disabled = true; }); onReady(false); status.innerText = "正在加载标签..."; retry.hidden = true; retry.disabled = true; - LoadAccountBadge(content, background, color, () => { + LoadAccountBadge(content, background, color, exists => { state.loaded = true; - [content, background, color].forEach(input => { input.disabled = false; }); + state.exists = exists; + [content, background, color].forEach(input => { input.disabled = !exists; }); status.innerText = ""; retry.disabled = false; - onReady(true); + onReady(true, exists); }, message => { status.innerText = "标签加载失败:" + message; retry.hidden = false; @@ -2728,11 +2738,12 @@ function InitializeAccountBadgeEditor() { let button = editor.querySelector("button"); let status = editor.querySelector("[role='status']"); container.appendChild(editor); - let badgeState = InitializeAccountBadgeLoading(content, background, color, status, loaded => { - button.disabled = !loaded; + let badgeState = InitializeAccountBadgeLoading(content, background, color, status, (loaded, exists) => { + editor.hidden = loaded && !exists; + button.disabled = !loaded || !exists; }); button.addEventListener("click", async () => { - if (!badgeState.loaded) return; + if (!badgeState.loaded || !badgeState.exists) return; button.disabled = true; status.innerText = ""; try { diff --git a/package.json b/package.json index 69334302..3938b5a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.8.4", + "version": "3.8.5", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs index 2530147d..e32c1ca9 100644 --- a/tests/account-settings.test.cjs +++ b/tests/account-settings.test.cjs @@ -13,7 +13,8 @@ function Between(start, end) { assert.ok(source.indexOf(start) < source.indexOf(end)); return source.slice(source.indexOf(start), source.indexOf(end)); } -const helpers = Between('function IsAccountSettingsPage(', 'function InitializeUserMenu('); +const helpers = Between('function IsMissingUserBadge(', 'function InitializeProfileBadge(') + + Between('function IsAccountSettingsPage(', 'function InitializeUserMenu('); const earlyRedirect = Between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') + Between('// Set to true by the early block', 'const CaptchaSiteKey'); const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); @@ -60,6 +61,11 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => window.pendingBadgeRequest = request; return; } + if (mode === 'missing') { + request.onload({status: 200, responseText: JSON.stringify({Success: false, + Message: options.missingBadgeMessage ?? '获取标签失败,该标签在数据库中不存在', Data: {}})}); + return; + } if (['network', 'timeout', 'abort'].includes(mode)) { window.requestTimeout = request.timeout; request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]](); @@ -147,6 +153,28 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge'); await page.close(); }); + for (const missingBadgeMessage of ['获取标签失败,该标签在数据库中不存在', ' 该标签在数据库中不存在。 ', '标签不存在']) { + await t.test('hides account tag editing for a missing badge: ' + missingBadgeMessage, async () => { + const page = await Page('/modify_user_info.php', nativeForm, {badgeLoadFailureMode: 'missing', missingBadgeMessage}); + const editor = page.locator('#UserScriptBadgeEditor'); + assert.equal(await editor.isHidden(), true); + assert.equal(await editor.locator('[role="status"]').textContent(), ''); + assert.equal(await editor.locator('button:has-text("重试加载标签")').isHidden(), true); + assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), ''); + assert.equal(await page.locator('#UserScriptBadgeContent').isDisabled(), true); + // Even a programmatic click must not submit an EditBadge request for a missing row. + await page.evaluate(() => { + const button = document.querySelector('#UserScriptBadgeEditor button'); + button.disabled = false; + button.click(); + }); + assert.deepEqual(await page.evaluate(() => apiCalls), [{action: 'GetBadge', data: {UserID: 'Tester'}}]); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname'); + await page.close(); + }); + } for (const failure of [false, true]) { await t.test('badge save ' + (failure ? 'reports failure and retains cache' : 'clears only this user’s badge cache'), async () => { const page = await Page('/modify_user_info.php', nativeForm, {badgeFailure: failure}); @@ -219,13 +247,23 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => } } await t.test('badge access denial leaves the native form usable', async () => { - const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true}); + const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true, badgeData: {}}); assert.equal(await page.locator('#UserScriptBadgeContent').isDisabled(), true); assert.match(await page.getByRole('status').innerText(), /Load denied/); await page.getByRole('button', {name: 'Save account'}).click(); assert.equal(await page.evaluate(() => nativeSubmitCount), 1); await page.close(); }); + await t.test('a retry finding no badge clears the error and hides the editor', async () => { + const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true}); + assert.match(await page.getByRole('status').innerText(), /Load denied/); + await page.evaluate(() => { window.badgeLoadFailureMode = 'missing'; }); + await page.getByRole('button', {name: '重试加载标签'}).click(); + assert.equal(await page.locator('#UserScriptBadgeEditor').isHidden(), true); + assert.equal(await page.locator('#UserScriptBadgeEditor [role="status"]').textContent(), ''); + assert.equal(await page.locator('#UserScriptBadgeEditor button').first().isDisabled(), true); + await page.close(); + }); for (const route of ['/modify_user_info.php']) { const content = '#UserScriptBadgeContent'; const save = 'button:has-text("修改标签")'; diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs index 13674a5c..5530ac7b 100644 --- a/tests/profile-page.test.cjs +++ b/tests/profile-page.test.cjs @@ -56,10 +56,21 @@ test('profile page browser regressions', {timeout: 60000}, async t => { request.onload({status: mode === 'http' ? 503 : 200, responseText: 'Error'}); return; } - if (!mode && action === 'NewBadge') window.badgeData = {Content: 'New badge'}; - if (!mode && action === 'DeleteBadge') window.badgeData = null; + if (mode === 'missing') { + request.onload({status: 200, responseText: JSON.stringify({Success: false, + Message: options.missingBadgeMessage ?? '获取标签失败,该标签在数据库中不存在', Data: {}})}); + return; + } + if (!mode && action === 'NewBadge') { + window.badgeData = {Content: 'New badge'}; + window.badgeMode = null; + } + if (!mode && action === 'DeleteBadge') { + window.badgeData = null; + window.badgeMode = 'missing'; + } request.onload({status: 200, responseText: JSON.stringify({Success: mode !== 'denied', Message: 'Denied', - Data: action === 'GetBadge' ? badgeData : action === 'LastOnline' ? {logintime: onlineTimestamp} : {}})}); + Data: action === 'GetBadge' ? (mode === 'denied' ? {} : badgeData) : action === 'LastOnline' ? {logintime: onlineTimestamp} : {}})}); }; window.nativeStats = document.getElementById('statics'); window.nativePie = document.getElementById('PieDiv'); @@ -132,10 +143,11 @@ test('profile page browser regressions', {timeout: 60000}, async t => { }); for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'http', 'denied']) { await t.test('preserves the profile and retries a ' + mode + ' badge failure', async () => { - const page = await Page({badgeMode: mode, onlineMode: mode}); + const page = await Page({badgeMode: mode, onlineMode: mode, admin: true}); await AssertCore(page); assert.match(await page.getByRole('status').innerText(), /标签暂不可用/); assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isVisible(), true); + assert.equal(await page.getByRole('button', {name: '添加标签'}).count(), 0); await page.evaluate(() => { window.badgeMode = null; }); await page.getByRole('button', {name: '重试加载标签'}).click(); assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一'); @@ -143,6 +155,42 @@ test('profile page browser regressions', {timeout: 60000}, async t => { await page.close(); }); } + for (const admin of [false, true]) { + await t.test('an absent database badge is an empty profile state with admin=' + admin, async () => { + const page = await Page({badgeMode: 'missing', admin}); + await AssertCore(page); + assert.equal(await page.locator('#UserScriptProfileBadge').isHidden(), true); + assert.equal(await page.getByRole('status').innerText(), ''); + assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isHidden(), true); + assert.equal(await page.getByRole('button', {name: '添加标签'}).count(), admin ? 1 : 0); + assert.equal(await page.getByRole('button', {name: '删除标签'}).count(), 0); + if (admin) { + const add = page.getByRole('button', {name: '添加标签'}); + assert.equal(await add.isVisible(), true); + await page.evaluate(() => { window.actionMode = 'denied'; }); + await add.click(); + assert.equal(await add.isEnabled(), true); + assert.equal(await page.getByRole('status').innerText(), 'Denied'); + await page.evaluate(() => { window.actionMode = null; }); + await add.click(); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), 'New badge'); + assert.equal(await page.getByRole('button', {name: '删除标签'}).isVisible(), true); + assert.equal(await page.getByRole('status').innerText(), ''); + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'NewBadge').map(call => call.data)), + [{UserID: 'ProfileTarget'}, {UserID: 'ProfileTarget'}]); + } + await page.close(); + }); + } + for (const missingBadgeMessage of [' 该标签在数据库中不存在。 ', '标签不存在']) { + await t.test('recognizes a missing tag after message wording changes: ' + missingBadgeMessage, async () => { + const page = await Page({badgeMode: 'missing', missingBadgeMessage, admin: true}); + assert.equal(await page.getByRole('status').innerText(), ''); + assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true); + assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isHidden(), true); + await page.close(); + }); + } for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) { await t.test('handles missing badge data: ' + JSON.stringify(badgeData), async () => { const page = await Page({badgeData, admin: true});