Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Update.json
Original file line number Diff line number Diff line change
Expand Up @@ -3861,6 +3861,17 @@
}
],
"Notes": "<p>修复浏览器过滤规则隐藏导航栏占位元素、导致页面顶部内容被遮挡的问题。</p>"
},
"3.8.5": {
"UpdateDate": 1791201776409,
"Prerelease": true,
"UpdateContents": [
{
"PR": 1045,
"Description": "fix: restore admin add-tag button for users without tags"
}
],
"Notes": "<p>修复用户没有标签时个人中心误显示加载错误和重试按钮的问题,恢复管理员的添加标签入口。修改账号页面在没有标签时隐藏标签编辑,避免提交无效修改;实际请求失败时仍可重试。</p>"
}
}
}
37 changes: 24 additions & 13 deletions XMOJ.user.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 === "";
Expand Down Expand Up @@ -2635,41 +2642,44 @@ 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";
retry.innerText = "重试加载标签";
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;
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
42 changes: 40 additions & 2 deletions tests/account-settings.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (');
Expand Down Expand Up @@ -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]]();
Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -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("修改标签")';
Expand Down
56 changes: 52 additions & 4 deletions tests/profile-page.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,21 @@ test('profile page browser regressions', {timeout: 60000}, async t => {
request.onload({status: mode === 'http' ? 503 : 200, responseText: '<html>Error</html>'});
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');
Expand Down Expand Up @@ -132,17 +143,54 @@ 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(), '省一');
assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isHidden(), true);
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});
Expand Down