diff --git a/README.md b/README.md
index 7c5439c2..be626280 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
### 新版比赛页面兼容性
-支持新版 `/web/contest/` 比赛列表、题目列表、题面、订正排名、标程和题解页面。脚本保留新版应用的导航、语言切换和排序,并使用与旧版页面相同的 Bootstrap 5 界面与导航栏行为(新版顶部导航栏、用户菜单、翻译等),在页面异步加载及站内切换后应用界面主题、倒计时、复制、讨论入口、批量打开题目和代码阅读器等增强功能。比赛题号及文件输入输出信息通过新版 API 同步,提交页面不再依赖旧版比赛 HTML。相关问题:[#1029](https://github.com/XMOJ-Script-dev/XMOJ-Script/issues/1029)。
+支持新版 `/web/contest/` 比赛列表、题目列表、题面、订正排名、标程和题解页面。脚本保留新版应用的导航和排序,统一使用中文,并使用与旧版页面相同的 Bootstrap 5 界面与导航栏行为(新版顶部导航栏、用户菜单、翻译等),在页面异步加载及站内切换后应用界面主题、倒计时、复制、讨论入口、批量打开题目和代码阅读器等增强功能。比赛题号及文件输入输出信息通过新版 API 同步,提交页面不再依赖旧版比赛 HTML。相关问题:[#1029](https://github.com/XMOJ-Script-dev/XMOJ-Script/issues/1029)。
### 介绍
diff --git a/Update.json b/Update.json
index 8dc5b4f8..362302d3 100644
--- a/Update.json
+++ b/Update.json
@@ -3839,6 +3839,28 @@
}
],
"Notes": "
修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。
"
+ },
+ "3.8.3": {
+ "UpdateDate": 1791106754384,
+ "Prerelease": true,
+ "UpdateContents": [
+ {
+ "PR": 1040,
+ "Description": "fix: restore account and profile pages and enforce Chinese UI"
+ }
+ ],
+ "Notes": "修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文,隐藏账号表单中的语言选项并统一提交中文。修复最后在线时间显示为 HTML 源码的问题。重新绘制个人中心提交与正确数量的历史图表,兼容浅色、深色主题及移动端,默认清理设置不再隐藏图表。提交与正确数量在同一图表中以细线和半透明柱形叠加展示,减少密集曲线遮挡。恢复暂存更改中的 API 请求超时和错误日志。
"
+ },
+ "3.8.4": {
+ "UpdateDate": 1791114523418,
+ "Prerelease": true,
+ "UpdateContents": [
+ {
+ "PR": 1044,
+ "Description": "fix: keep navbar spacer visible under browser cosmetic filters"
+ }
+ ],
+ "Notes": "修复浏览器过滤规则隐藏导航栏占位元素、导致页面顶部内容被遮挡的问题。
"
}
}
}
\ No newline at end of file
diff --git a/XMOJ.user.js b/XMOJ.user.js
index ea1c87d8..5f659d6e 100644
--- a/XMOJ.user.js
+++ b/XMOJ.user.js
@@ -1,6 +1,6 @@
// ==UserScript==
// @name XMOJ
-// @version 3.8.2
+// @version 3.8.4
// @description XMOJ增强脚本
// @author @XMOJ-Script-dev, @langningchen and the community
// @namespace https://github/langningchen
@@ -44,6 +44,115 @@
* You should have received a copy of the GNU General Public License along with this program. If not, see .
*/
+// Set the app preference at document-start, before Vue requests /api/nav.
+function InitializeChineseLanguage() {
+ let wasEnglish = document.cookie.split(";").some(cookie => cookie.trim() == "XMOJ_LANG=en");
+ document.cookie = "XMOJ_LANG=zh; path=/; max-age=31536000; SameSite=Lax" + (location.protocol == "https:" ? "; Secure" : "");
+ return wasEnglish;
+}
+
+const chineseAccountForms = new WeakSet();
+function EnforceChineseAccountLanguage(root) {
+ const radios = [...root.querySelectorAll('input[type="radio"]')];
+ const Matches = (radio, language) => {
+ const label = [...radio.labels || []].map(node => node.textContent.trim());
+ return language == "zh" ? /^(cn|zh|zh[-_]cn)$/i.test(radio.value) || label.some(text => /^(中文|简体中文|Chinese)$/i.test(text)) :
+ /^en(?:[-_]us)?$/i.test(radio.value) || label.some(text => /^English$/i.test(text));
+ };
+ for (const chinese of radios.filter(radio => Matches(radio, "zh"))) {
+ const english = radios.find(radio => radio !== chinese && radio.name === chinese.name && radio.form === chinese.form && Matches(radio, "en"));
+ if (!chinese.name || !english) continue;
+ if (!chinese.checked) chinese.click();
+ chinese.setAttribute("data-xmoj-script-chinese-language", "");
+ let group = chinese.parentElement;
+ while (group && !group.contains(english)) group = group.parentElement;
+ if (group && !group.matches("form, body, html") && !group.querySelector('input:not([type="radio"]), select, textarea, button')) {
+ group.setAttribute("data-xmoj-script-language-selector", "");
+ const heading = group.previousElementSibling;
+ if (heading && /^(界面语言|语言|Language)$/i.test(heading.textContent.trim())) heading.setAttribute("data-xmoj-script-language-selector", "");
+ } else {
+ for (const radio of [chinese, english]) {
+ radio.setAttribute("data-xmoj-script-language-selector", "");
+ for (const label of radio.labels || []) label.setAttribute("data-xmoj-script-language-selector", "");
+ }
+ }
+ for (const label of root.querySelectorAll("label, legend")) {
+ if (/^(界面语言|语言|Language)$/i.test(label.textContent.trim())) label.setAttribute("data-xmoj-script-language-selector", "");
+ }
+ const form = chinese.form;
+ if (form && !chineseAccountForms.has(form)) {
+ chineseAccountForms.add(form);
+ // Keep the native field enabled so the hidden Chinese value is submitted.
+ form.addEventListener("submit", () => EnforceChineseAccountLanguage(form), true);
+ form.addEventListener("formdata", event => {
+ EnforceChineseAccountLanguage(form);
+ for (const radio of form.querySelectorAll("input[data-xmoj-script-chinese-language]")) event.formData.set(radio.name, radio.value);
+ });
+ }
+ }
+}
+
+function EnforceChineseView(root = document) {
+ document.documentElement.lang = "zh-CN";
+ if (!document.getElementById("UserScript-ChineseLanguage")) {
+ let style = document.createElement("style");
+ style.id = "UserScript-ChineseLanguage";
+ style.textContent = `#lang_cn_to_en, #lang_en_to_cn, .xmoj-lang-switch,
+ [data-xmoj-script-language-selector] { display: none !important; }
+ .lang_en { display: none !important; }
+ .lang_cn { display: revert !important; }`;
+ document.head.appendChild(style);
+ }
+ if (location.pathname === "/modify_user_info.php") EnforceChineseAccountLanguage(root);
+ // Keep Vue's nodes and handlers intact; it can replace the navbar on navigation.
+ for (let link of root.querySelectorAll('#xmoj-navbar > ul.navbar-right > li > a[href="#"]:not(.dropdown-toggle)')) {
+ if (/^(English|中文)$/.test(link.textContent.trim())) link.setAttribute("data-xmoj-script-language-selector", "");
+ }
+ // The problem/solution selector has its own reactive language, separate from nav.
+ for (let button of root.querySelectorAll(".xmoj-lang-switch button")) {
+ if (button.textContent.trim() == "中文" && !button.classList.contains("hidden") && !button.hidden) button.click();
+ }
+}
+
+async function EnsureChinesePage() {
+ EnforceChineseView();
+ const retryKey = "UserScript-ChineseLanguageReload";
+ const pageKey = location.pathname + location.search + location.hash;
+ // Classic pages persist the preference through the current account form.
+ // change_lang.php is defunct; do not call it or reload classic pages for it.
+ if (!(IsContestWebApp() && initiallyEnglishLanguage)) {
+ sessionStorage.removeItem(retryKey);
+ return false;
+ }
+ try {
+ // A server that refuses the preference must not trap the user in a reload loop.
+ if (sessionStorage.getItem(retryKey) == pageKey) throw new Error("中文设置未生效");
+ sessionStorage.setItem(retryKey, pageKey);
+ location.reload();
+ } catch (error) {
+ console.error("[XMOJ-Script] Chinese language:", error);
+ let alert = document.createElement("div");
+ alert.className = "alert alert-warning";
+ alert.role = "alert";
+ alert.textContent = "切换中文失败,请检查网络后重试。 ";
+ let retry = document.createElement("button");
+ retry.type = "button";
+ retry.className = "btn btn-outline-secondary";
+ retry.textContent = "重试";
+ retry.addEventListener("click", () => {
+ sessionStorage.removeItem(retryKey);
+ location.reload();
+ });
+ alert.appendChild(retry);
+ document.body.prepend(alert);
+ RevealPage();
+ return false;
+ }
+ return true;
+}
+
+const initiallyEnglishLanguage = InitializeChineseLanguage();
+
// The /web application owns its DOM. Never run the legacy body.innerHTML rewrites
// inside it; InitializeContestWebApp maps its Bootstrap 3 markup to Bootstrap 5.
function IsContestWebApp(pathname = location.pathname) {
@@ -605,6 +714,11 @@ let _earlyObs = null;
// the saved theme and inject Bootstrap CSS + the skin CSS before the first paint,
// and we block the page's own old stylesheets from loading at all.
(() => {
+ const AccountRedirect = GetAccountSettingsRedirect();
+ if (AccountRedirect) {
+ location.replace(AccountRedirect);
+ return;
+ }
// Old contest pages: move to the /web app before anything is drawn.
const ContestWebRedirect = GetContestWebRedirect();
if (ContestWebRedirect) {
@@ -1695,7 +1809,14 @@ let clearCredential = async () => {
}
}
};
-let RequestAPI = (Action, Data, CallBack) => {
+let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => {
+ let completed = false;
+ let Fail = (message) => {
+ if (completed) return;
+ completed = true;
+ if (ErrorCallBack) ErrorCallBack(message);
+ else console.error("[XMOJ-Script] Request " + Action + ": " + message);
+ };
try {
let Session = "";
let Temp = document.cookie.split(";");
@@ -1738,18 +1859,38 @@ let RequestAPI = (Action, Data, CallBack) => {
"DebugMode": UtilityEnabled("DebugMode")
},
data: DataString,
+ // Preserve the stashed general request limit and faster badge retries.
+ timeout: ErrorCallBack ? 15000 : 30000,
+ onerror: () => Fail("网络错误,请重试"),
+ ontimeout: () => Fail("请求超时,请重试"),
+ onabort: () => Fail("请求已取消,请重试"),
onload: (Response) => {
+ if (completed) return;
if (UtilityEnabled("DebugMode")) {
console.log("Received for", Action + ":", Response.responseText);
}
+ if (ErrorCallBack && (Response.status < 200 || Response.status >= 300)) {
+ Fail("请求失败(HTTP " + Response.status + "),请重试");
+ return;
+ }
+ let result;
try {
- CallBack(JSON.parse(Response.responseText));
+ result = JSON.parse(Response.responseText);
} catch (Error) {
console.log(Response.responseText);
+ Fail("服务器响应异常,请重试");
+ return;
+ }
+ completed = true;
+ try {
+ if (CallBack) CallBack(result);
+ } catch (Error) {
+ console.error(Error);
}
}
});
} catch (e) {
+ Fail("请求失败,请重试");
console.error(e);
if (UtilityEnabled("DebugMode")) {
SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode.");
@@ -2148,15 +2289,470 @@ function CreateProblemSwitcher(ProblemList, IsCurrent) {
return problemSwitcher;
}
+// Read server-rendered links, or the numeric p(id, count) calls in unexecuted HTML.
+// Never eval profile scripts: document.write after page load can replace the page.
+function GetProfileSolvedProblems(root = document) {
+ let table = root.querySelector("#statics");
+ let problems = new Set();
+ for (let link of table?.querySelectorAll("a[href]") || []) {
+ try {
+ let url = new URL(link.getAttribute("href"), location.href);
+ let value = url.searchParams.get("id");
+ let id = Number(value);
+ if (url.pathname == "/problem.php" && /^\d+$/.test(value) && Number.isSafeInteger(id) && id > 0) problems.add(id);
+ } catch { /* Ignore malformed links rather than aborting the profile. */ }
+ }
+ for (let script of table?.querySelectorAll("script") || []) {
+ for (let match of script.textContent.matchAll(/\bp\(\s*(\d+)\s*,\s*\d+\s*\)/g)) {
+ if (Number.isSafeInteger(Number(match[1])) && Number(match[1]) > 0) problems.add(Number(match[1]));
+ }
+ }
+ return [...problems];
+}
+
+function InitializeProfileBadge(container, userID, isAdmin) {
+ let badge = document.createElement("span");
+ badge.className = "badge me-2";
+ badge.id = "UserScriptProfileBadge";
+ let status = document.createElement("span");
+ status.setAttribute("role", "status");
+ let retry = document.createElement("button");
+ retry.type = "button";
+ retry.className = "btn btn-outline-secondary btn-sm";
+ retry.textContent = "重试加载标签";
+ let controls = document.createElement("span");
+ container.append(badge, status, retry, controls);
+ const ClearCache = () => {
+ let keys = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ let key = localStorage.key(i);
+ if (key.startsWith("UserScript-User-" + userID + "-Badge-")) keys.push(key);
+ }
+ keys.forEach(key => localStorage.removeItem(key));
+ };
+ const Fail = message => {
+ status.textContent = "标签暂不可用:" + message;
+ retry.hidden = false;
+ retry.disabled = false;
+ };
+ const Load = () => {
+ status.textContent = "正在加载标签...";
+ retry.hidden = true;
+ retry.disabled = true;
+ controls.replaceChildren();
+ RequestAPI("GetBadge", {UserID: userID}, response => {
+ if (!response?.Success) { Fail(response?.Message || "服务器响应异常,请重试"); return; }
+ let data = response.Data ?? {};
+ let content = String(data.Content ?? "");
+ badge.textContent = content;
+ badge.hidden = content === "";
+ badge.style.backgroundColor = /^#[0-9a-f]{6}$/i.test(data.BackgroundColor) ? data.BackgroundColor : "#000000";
+ badge.style.color = /^#[0-9a-f]{6}$/i.test(data.Color) ? data.Color : "#ffffff";
+ status.textContent = "";
+ retry.disabled = false;
+ if (!isAdmin) return;
+ let button = document.createElement("button");
+ button.type = "button";
+ button.className = "btn btn-outline-primary btn-sm";
+ button.textContent = content === "" ? "添加标签" : "删除标签";
+ controls.appendChild(button);
+ button.addEventListener("click", () => {
+ if (content !== "" && !confirm("您确定要删除此标签吗?")) return;
+ button.disabled = true;
+ const Failed = message => { status.textContent = message; button.disabled = false; };
+ RequestAPI(content === "" ? "NewBadge" : "DeleteBadge", {UserID: userID}, result => {
+ if (!result?.Success) { Failed(result?.Message || "服务器响应异常,请重试"); return; }
+ ClearCache();
+ Load();
+ }, Failed);
+ });
+ }, Fail);
+ };
+ retry.addEventListener("click", Load);
+ Load();
+}
+
+function GetProfileActivityData(root = document) {
+ // Read only the numeric history statements emitted by userinfo.php. Never eval
+ // the page's graph initializer: the old Flot renderer is unreliable here.
+ const script = [...root.querySelectorAll("script")].find(node => /\$\.plot\s*\(/.test(node.textContent) && /["']#submission["']/.test(node.textContent));
+ if (!script) return null;
+ const series = [new Map(), new Map()];
+ for (const match of script.textContent.matchAll(/\bd([12])\.push\(\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]\s*\)\s*;?/g)) {
+ const timestamp = Number(match[2]), count = Number(match[3]);
+ if (!Number.isSafeInteger(timestamp) || timestamp <= 0 || timestamp > 8640000000000000 || !Number.isSafeInteger(count)) continue;
+ series[Number(match[1]) - 1].set(timestamp, count);
+ }
+ return series.map(points => [...points].sort((a, b) => a[0] - b[0]));
+}
+
+function InitializeProfileActivityChart(chart) {
+ const data = GetProfileActivityData();
+ chart.style.cssText = "width:100%;max-width:600px;text-align:left;border:1px solid var(--bs-border-color, #adb5bd);box-sizing:border-box;padding:12px;margin-top:12px";
+ chart.replaceChildren();
+ if (!data || !data.some(points => points.length)) {
+ chart.textContent = data ? "暂无提交记录" : "暂时无法读取提交记录";
+ chart.setAttribute("role", "status");
+ return;
+ }
+ const colors = ["#d4a017", "#4799cc"], labels = ["提交", "正确"];
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("role", "img");
+ svg.setAttribute("aria-label", "提交与正确数量随时间的变化");
+ svg.style.cssText = "display:block;width:100%;height:auto";
+ chart.appendChild(svg);
+ const Node = (tag, attributes, text, parent = svg) => {
+ const node = document.createElementNS(svg.namespaceURI, tag);
+ for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
+ if (text != null) node.textContent = text;
+ parent.appendChild(node);
+ return node;
+ };
+ const date = new Intl.DateTimeFormat("zh-CN", {timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit"});
+ const DateLabel = timestamp => date.format(new Date(timestamp)).replaceAll("/", "-");
+ const points = data.flat();
+ let min = Math.min(...points.map(point => point[0])), max = Math.max(...points.map(point => point[0]));
+ const day = 86400000;
+ min -= day / 2;
+ max += day / 2;
+ const maximum = Math.max(1, ...points.map(point => point[1]));
+ const power = 10 ** Math.floor(Math.log10(maximum / 4));
+ const step = Math.max(1, [1, 2, 5, 10].find(value => value * power >= maximum / 4) * power);
+ const limit = Math.ceil(maximum / step) * step;
+ let previousWidth = 0;
+ const Draw = () => {
+ const width = Math.max(280, Math.min(600, svg.clientWidth || 600));
+ if (width === previousWidth) return;
+ previousWidth = width;
+ svg.replaceChildren();
+ svg.setAttribute("viewBox", "0 0 " + width + " 280");
+ const left = 45, right = width - 12, top = 28, bottom = 238;
+ const X = timestamp => left + (timestamp - min) / (max - min) * (right - left);
+ const Y = count => bottom - count / limit * (bottom - top);
+ for (let tick = 0; tick <= limit; tick += step) {
+ Node("line", {x1: left, x2: right, y1: Y(tick), y2: Y(tick), stroke: "var(--bs-border-color, #adb5bd)", opacity: "0.5"});
+ Node("text", {x: left - 6, y: Y(tick) + 4, "text-anchor": "end", "font-size": "12", fill: "var(--bs-body-color, currentColor)"}, Number(tick.toPrecision(12)));
+ }
+ for (let index = 0; index < labels.length; index++) Node("text", {x: left + index * 65, y: 16, "font-size": "13", fill: colors[index]}, labels[index]);
+ // Paint translucent accepted bars first, with a thin submission line above
+ // them. Invisible hover targets preserve tooltips without dense dot markers.
+ const accepted = Node("g", {"data-layer": "accepted"});
+ const barWidth = Math.max(1, Math.min(12, (right - left) * day / (max - min) * 0.6));
+ for (const [timestamp, count] of data[1]) {
+ const bar = Node("rect", {x: X(timestamp) - barWidth / 2, y: Y(count), width: barWidth, height: bottom - Y(count), fill: colors[1], "fill-opacity": "0.55", "data-series": "accepted"}, null, accepted);
+ Node("title", {}, DateLabel(timestamp) + " 正确:" + count, bar);
+ }
+ const submitted = Node("g", {"data-layer": "submitted"});
+ if (data[0].length) Node("path", {d: data[0].map(([timestamp, count], index) => (index ? "L" : "M") + X(timestamp) + " " + Y(count)).join(" "), fill: "none", stroke: colors[0], "stroke-width": "1.25", "stroke-opacity": "0.8", "data-series": "submitted"}, null, submitted);
+ for (const [timestamp, count] of data[0]) {
+ const point = Node("circle", {cx: X(timestamp), cy: Y(count), r: data[0].length === 1 ? "3" : "5", fill: data[0].length === 1 ? colors[0] : "transparent", "data-series": "submitted"}, null, submitted);
+ Node("title", {}, DateLabel(timestamp) + " 提交:" + count, point);
+ }
+ const singleDate = max - min === day;
+ const ticks = singleDate ? 1 : Math.max(2, Math.floor((right - left) / 110));
+ for (let i = 0; i < ticks; i++) {
+ const timestamp = singleDate ? min + day / 2 : min + day / 2 + i / (ticks - 1) * (max - min - day);
+ Node("text", {x: X(timestamp), y: bottom + 22, "text-anchor": singleDate ? "middle" : i === 0 ? "start" : i === ticks - 1 ? "end" : "middle", "font-size": "11", fill: "var(--bs-body-color, currentColor)", "data-axis": "date"}, DateLabel(timestamp));
+ }
+ };
+ Draw();
+ if (typeof ResizeObserver === "function") {
+ const observer = new ResizeObserver(() => {
+ if (!chart.isConnected) { observer.disconnect(); return; }
+ Draw();
+ });
+ observer.observe(chart);
+ }
+}
+
+function InitializeProfileChart(table) {
+ let chart = table.querySelector("#PieDiv");
+ if (!chart) return;
+ let entries = [...table.rows].filter(row => row.cells[1]?.querySelector('a[href*="jresult="]') && !/^(解决|Solved)/i.test(row.cells[0]?.textContent.trim()))
+ .map(row => ({label: row.cells[0].textContent.trim(), value: Number(row.cells[1].textContent.trim())}))
+ .filter(entry => Number.isFinite(entry.value) && entry.value >= 0);
+ let total = entries.reduce((sum, entry) => sum + entry.value, 0);
+ if (!entries.length) return;
+ let colors = ["#FF8080", "#8080FF", "#80bb80", "#FF0066", "#9900FF", "#996633", "#006633", "#000000", "#66cddd", "#0066FF"];
+ let pie = document.createElement("div");
+ pie.setAttribute("role", "img");
+ pie.setAttribute("aria-label", "判题结果分布");
+ pie.style.cssText = "width:100px;height:100px;border-radius:50%;flex-shrink:0";
+ pie.style.setProperty("border-radius", "50%", "important");
+ let legend = document.createElement("ul");
+ legend.className = "list-unstyled mb-0";
+ legend.style.textAlign = "left";
+ let position = 0;
+ let segments = entries.map((entry, index) => {
+ let start = position;
+ position += total ? entry.value / total * 100 : 0;
+ let color = colors[index % colors.length];
+ let item = document.createElement("li");
+ let marker = document.createElement("span");
+ marker.style.cssText = "display:inline-block;width:10px;height:10px;margin-right:6px;background:" + color;
+ item.append(marker, document.createTextNode(entry.label + " [" + (total ? Math.round(entry.value / total * 100) : 0) + "%]"));
+ legend.appendChild(item);
+ return color + " " + start + "% " + position + "%";
+ });
+ pie.style.background = total ? "conic-gradient(" + segments.join(",") + ")" : "#808080";
+ // The old canvas positions ten labels within 100px; normal flow prevents overlap.
+ chart.style.cssText = "display:flex;flex-wrap:wrap;align-items:center;gap:12px;width:auto;height:auto";
+ chart.replaceChildren(pie, legend);
+}
+
+// Commit the complete profile before any optional API call can fail or stall.
+function InitializeUserProfile(isAdmin = false) {
+ if (location.pathname != "/userinfo.php" || new URLSearchParams(location.search).has("ByUserScript") || document.getElementById("UserScriptProfile")) return;
+ let table = document.getElementById("statics");
+ if (!table) return;
+ let caption = table.caption?.cloneNode(true);
+ caption?.querySelectorAll("a").forEach(link => link.remove());
+ let identity = caption?.textContent.trim() || "";
+ let separator = identity.indexOf("--");
+ let userID = separator >= 0 ? identity.slice(0, separator).trim() : new URLSearchParams(location.search).get("user");
+ let nickname = separator >= 0 ? identity.slice(separator + 2).trim() : identity;
+ if (!userID) return;
+ let problems = GetProfileSolvedProblems();
+ let rows = [...table.rows];
+ const Value = pattern => rows.find(row => pattern.test(row.cells[0]?.textContent.trim() || ""))?.cells[1]?.textContent.trim();
+ let email = Value(/^(电子邮箱|Email:?)$/i) || "";
+ let submitted = Number(Value(/^(提交|Submit(?:ted)?|Submissions?:?)$/i));
+ let accepted = Number(Value(/^(正确|Accepted:?)$/i));
+ let rating = submitted > 0 && Number.isFinite(accepted) ? Math.round(accepted / submitted * 1000) : 0;
+ let profile = document.createElement("div");
+ profile.id = "UserScriptProfile";
+ profile.className = "row text-start text-left";
+ let left = document.createElement("div");
+ left.className = "col-md-5";
+ let right = document.createElement("div");
+ right.className = "col-md-7";
+ profile.append(left, right);
+ let header = document.createElement("div");
+ header.className = "row mb-2";
+ let avatarContainer = document.createElement("div");
+ avatarContainer.className = "col-auto";
+ let avatar = document.createElement("img");
+ avatar.className = "rounded me-2";
+ avatar.alt = userID + " 的头像";
+ avatar.width = avatar.height = 120;
+ avatar.src = "https://cravatar.cn/avatar/00000000000000000000000000000000?d=mp&f=y";
+ // The native profile already contains the email and statistics. No second
+ // profile fetch is needed to show the avatar or compute the rating.
+ if (email) {
+ try { avatar.src = "https://cravatar.cn/avatar/" + CryptoJS.MD5(email).toString() + "?d=retro"; }
+ catch (error) { console.error("[XMOJ-Script] Profile avatar:", error); }
+ }
+ avatarContainer.appendChild(avatar);
+ let info = document.createElement("div");
+ info.className = "col-auto";
+ info.style.lineHeight = "40px";
+ const Line = text => {
+ let line = document.createElement("div");
+ line.textContent = text;
+ info.appendChild(line);
+ return line;
+ };
+ Line("用户名:" + userID);
+ Line("昵称:" + nickname);
+ if (UtilityEnabled("Rating")) Line("评分:" + rating);
+ let lastOnline = Line("最后在线:加载中...");
+ lastOnline.id = "UserScriptProfileLastOnline";
+ let badges = document.createElement("div");
+ info.appendChild(badges);
+ header.append(avatarContainer, info);
+ left.appendChild(header);
+ let heading = document.createElement("h5");
+ heading.textContent = "已解决题目";
+ right.appendChild(heading);
+ let solved = document.createElement("div");
+ solved.id = "UserScriptProfileSolved";
+ solved.style.lineHeight = "1.8";
+ for (let id of problems) {
+ let link = document.createElement("a");
+ link.href = "/problem.php?id=" + id;
+ link.target = "_blank";
+ link.textContent = id;
+ solved.append(link, document.createTextNode(" "));
+ }
+ right.appendChild(solved);
+
+ // Move the original statistics nodes so their links and handlers survive.
+ table.insertAdjacentElement("beforebegin", profile);
+ for (let link of table.caption?.querySelectorAll("a") || []) info.appendChild(link);
+ table.caption?.remove();
+ let solvedCell = [...table.querySelectorAll("td[rowspan]")].find(cell =>
+ cell.querySelector('a[href*="problem.php"]') || [...cell.querySelectorAll("script")].some(script => /\bfunction\s+p\s*\(/.test(script.textContent)));
+ let activity = document.getElementById("submission");
+ if (solvedCell?.querySelector('a[href*="problem.php"]')) {
+ // Keep the native per-problem submission-count links and their handlers.
+ solved.replaceChildren();
+ let group = null;
+ for (let node of [...solvedCell.childNodes]) {
+ if (node.nodeName == "SCRIPT" || node === activity) continue;
+ if (node.nodeName == "A" && node.getAttribute("href")?.includes("problem.php")) {
+ group = document.createElement("span");
+ group.style.cssText = "display:inline-block;white-space:nowrap;margin-right:8px";
+ solved.appendChild(group);
+ }
+ (group || solved).appendChild(node);
+ }
+ }
+ // Submission history is functional and must survive the default cleanup setting.
+ if (activity) {
+ right.appendChild(activity);
+ InitializeProfileActivityChart(activity);
+ }
+ solvedCell?.remove();
+ let firstRow = table.rows[0];
+ if (firstRow?.cells.length >= 2 && !firstRow.cells[0].textContent.trim() && !firstRow.cells[1].textContent.trim()) firstRow.remove();
+ for (let row of table.rows) {
+ if (row.cells[0]?.textContent.trim() == "Statistics") row.cells[0].textContent = "统计";
+ if (row.cells[0]?.textContent.trim() == "Email:") row.cells[0].textContent = "电子邮箱";
+ for (let cell of row.cells) cell.removeAttribute("align");
+ }
+ table.removeAttribute("width");
+ left.appendChild(table);
+ InitializeProfileChart(table);
+ document.title = "用户 " + userID + " 的个人中心";
+ InitializeProfileBadge(badges, userID, isAdmin);
+ RequestAPI("LastOnline", {Username: userID}, response => {
+ lastOnline.innerHTML = "最后在线:" + (response?.Success && response.Data?.logintime != null ? GetRelativeTime(response.Data.logintime) : "暂无记录");
+ }, () => { lastOnline.textContent = "最后在线:暂不可用"; });
+}
+
+function IsAccountSettingsPage(pathname) {
+ return pathname == "/modify_user_info.php";
+}
+
+function GetAccountSettingsRedirect() {
+ return location.pathname == "/modifypage.php" ? "/modify_user_info.php" + location.search + location.hash : null;
+}
+
+function InitializeAccountFeatures(authenticated) {
+ if (!authenticated || !CurrentUsername || !IsAccountSettingsPage(location.pathname) || new URLSearchParams(location.search).has("ByUserScript")) return;
+ InitializeAccountBadgeEditor();
+}
+
+function LoadAccountBadge(content, background, color, onLoad, onError) {
+ RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => {
+ if (!response?.Success) {
+ onError(response?.Message || "服务器响应异常,请重试");
+ return;
+ }
+ let badge = 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();
+ }, 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 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;
+ [content, background, color].forEach(input => { input.disabled = true; });
+ onReady(false);
+ status.innerText = "正在加载标签...";
+ retry.hidden = true;
+ retry.disabled = true;
+ LoadAccountBadge(content, background, color, () => {
+ state.loaded = true;
+ [content, background, color].forEach(input => { input.disabled = false; });
+ status.innerText = "";
+ retry.disabled = false;
+ onReady(true);
+ }, message => {
+ status.innerText = "标签加载失败:" + message;
+ retry.hidden = false;
+ retry.disabled = false;
+ });
+ };
+ retry.addEventListener("click", Load);
+ Load();
+ return state;
+}
+
+function SaveAccountBadge(content, background, color) {
+ let userID = String(CurrentUsername);
+ return new Promise(resolve => {
+ RequestAPI("EditBadge", {
+ "UserID": userID,
+ "Content": String(content),
+ "BackgroundColor": String(background),
+ "Color": String(color)
+ }, (response) => {
+ if (!response?.Success) {
+ resolve({Success: false, Message: response?.Message || "服务器响应异常,请重试"});
+ return;
+ }
+ let keys = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ let key = localStorage.key(i);
+ if (key.startsWith("UserScript-User-" + userID + "-Badge-")) keys.push(key);
+ }
+ keys.forEach(key => localStorage.removeItem(key));
+ resolve(response);
+ }, message => resolve({Success: false, Message: message}));
+ });
+}
+
+// Keep the migrated site's form, including its submit handler and CSRF fields.
+// Script badges are saved separately so they cannot interfere with account edits.
+function InitializeAccountBadgeEditor() {
+ if (document.getElementById("UserScriptBadgeEditor")) return;
+ let container = document.querySelector("main") || document.querySelector("body > .container") || document.body;
+ let editor = document.createElement("div");
+ editor.id = "UserScriptBadgeEditor";
+ editor.className = "border p-2 my-3";
+ editor.innerHTML = `标签编辑
+ 内容
+
+ 背景颜色
+
+ 文字颜色
+
+ 修改标签
+
`;
+ let content = editor.querySelector("#UserScriptBadgeContent");
+ let background = editor.querySelector("#UserScriptBadgeBackground");
+ let color = editor.querySelector("#UserScriptBadgeColor");
+ 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;
+ });
+ button.addEventListener("click", async () => {
+ if (!badgeState.loaded) return;
+ button.disabled = true;
+ status.innerText = "";
+ try {
+ let result = await SaveAccountBadge(content.value, background.value, color.value);
+ status.innerText = result.Success ? "修改成功" : result.Message;
+ } finally {
+ button.disabled = false;
+ }
+ });
+}
+
// The ResetType user menu. Shared by the legacy navbar and the /web app so both
// menus offer the same entries and animate the same way.
function CreateUserMenuItems() {
let Entries = [
- ["修改帐号", () => { location.href = "https://www.xmoj.tech/modifypage.php"; }],
+ ["修改帐号", () => { location.href = "https://www.xmoj.tech/modify_user_info.php"; }],
["个人中心", () => { location.href = "https://www.xmoj.tech/userinfo.php?user=" + CurrentUsername; }],
["短消息", () => { location.href = "https://www.xmoj.tech/mail.php"; }],
["插件设置", () => { location.href = "https://www.xmoj.tech/index.php?ByUserScript=1"; }],
- ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modifypage.php?ByUserScript=1"; }],
+ ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modify_user_info.php?ByUserScript=1"; }],
["注销", () => {
clearCredential();
GM.cookie.set({
@@ -2749,6 +3345,7 @@ async function InitializeContestWebApp() {
return false;
});
if (UtilityEnabled("NewBootstrap")) ApplyBootstrap5Markup();
+ EnforceChineseView(root);
EnhanceNav();
if (!revealed && root.querySelector(".navbar")) {
// As on the legacy pages: top bar first, then show the page.
@@ -2845,13 +3442,23 @@ class NavbarStyler {
}
}
- preserveStyles(element, properties) {
+ preserveStyles(element, properties, preserveOrder = false) {
+ const order = preserveOrder ? Array.from(element.style) : null;
const original = properties.map(property => [property, element.style.getPropertyValue(property), element.style.getPropertyPriority(property)]);
return () => {
for (const [property, value, priority] of original) {
if (value) element.style.setProperty(property, value, priority);
else element.style.removeProperty(property);
}
+ if (order) {
+ // Attribute selectors can depend on declaration order. Restore that
+ // order while retaining unrelated styles changed by the page.
+ const current = Array.from(element.style);
+ const names = [...order.filter(name => current.includes(name)), ...current.filter(name => !order.includes(name))];
+ const values = names.map(name => [name, element.style.getPropertyValue(name), element.style.getPropertyPriority(name)]);
+ element.style.cssText = '';
+ for (const [name, value, priority] of values) element.style.setProperty(name, value, priority);
+ }
};
}
@@ -2862,9 +3469,11 @@ class NavbarStyler {
this.restoreNavbarStyles();
this.restoreOverlayStyles?.();
this.restoreSpacerStyles?.();
+ this.restoreSpacerLayoutStyles?.();
this.overlay?.remove();
this.overlayStyle?.remove();
this.spacer?.remove();
+ this.spacerStyle?.remove();
this.navbar = null;
}
@@ -2980,20 +3589,36 @@ class NavbarStyler {
try {
let spacer = document.getElementById('navbar-spacer');
let newHeight = this.navbar.offsetHeight + 24;
+ // Browser cosmetic filters can hide empty body children whose inline
+ // style starts with display:block; width:100%; height:. Keep static
+ // layout rules outside that attribute, including when Bootstrap is late.
+ if (!this.spacerStyle?.isConnected) {
+ this.spacerStyle = document.createElement('style');
+ this.spacerStyle.textContent = '#navbar-spacer { display: block; width: 100%; }';
+ (document.head || document.documentElement).appendChild(this.spacerStyle);
+ }
if (!spacer) {
// Legacy page handlers use body > div for the content container.
// A block span reserves space without becoming their first match.
spacer = document.createElement('span');
spacer.id = 'navbar-spacer';
spacer.setAttribute('aria-hidden', 'true');
- spacer.style.display = 'block';
- spacer.style.width = '100%';
document.body.insertBefore(spacer, document.body.firstChild);
this.spacer = spacer;
} else if (spacer !== this.spacer && spacer !== this.borrowedSpacer) {
+ this.restoreSpacerStyles?.();
+ this.restoreSpacerLayoutStyles?.();
+ this.restoreSpacerLayoutStyles = null;
this.borrowedSpacer = spacer;
this.restoreSpacerStyles = this.preserveStyles(spacer, ['height']);
}
+ // Older initializers may leave the same fingerprint on a borrowed spacer.
+ // Normalize only matching nodes, then restore their layout on teardown.
+ if (spacer !== this.spacer && spacer.matches('body > [style^="display: block; width: 100%; height:"]:empty')) {
+ if (!this.restoreSpacerLayoutStyles) this.restoreSpacerLayoutStyles = this.preserveStyles(spacer, ['display', 'width'], true);
+ spacer.style.removeProperty('display');
+ spacer.style.removeProperty('width');
+ }
// Update in place so an existing page-owned spacer is never replaced.
if (spacer.style.height !== `${newHeight}px`) spacer.style.height = `${newHeight}px`;
} catch (e) {
@@ -3009,6 +3634,7 @@ class NavbarStyler {
// which executes userscripts as classic scripts (not ES modules).
(async () => {
if (GetContestWebRedirect()) return;
+if (GetAccountSettingsRedirect()) return;
if (document.readyState === "loading") {
await new Promise(r => document.addEventListener("DOMContentLoaded", r, { once: true }));
}
@@ -3026,6 +3652,7 @@ if (_foucStyle) {
}
setTimeout(RevealPage, 4000);
}
+if (await EnsureChinesePage()) return;
if (IsContestWebApp()) {
await InitializeContestWebApp();
return;
@@ -3054,7 +3681,10 @@ if (profileElement === null) {
}
CurrentUsername = profileElement.innerText;
CurrentUsername = CurrentUsername.replaceAll(/[^a-zA-Z0-9]/g, "");
+// Initialize migrated account tools independently of the legacy navbar/layout handler.
+InitializeAccountFeatures(logined || (CurrentUsername && !/^(Login|Guest)$/i.test(CurrentUsername)));
let IsAdmin = AdminUserList.indexOf(CurrentUsername) !== -1;
+InitializeUserProfile(IsAdmin);
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = (theme) => {
@@ -3113,12 +3743,15 @@ async function main() {
if (UtilityEnabled("Translate")) {
if (document.querySelector("#navbar > ul:nth-child(1) > li:nth-child(2) > a") != null) document.querySelector("#navbar > ul:nth-child(1) > li:nth-child(2) > a").innerText = "题库";
}
- //send analytics
+ // Preserve native account listeners during page-wide customization.
RequestAPI("SendData", {});
- if (UtilityEnabled("ReplaceLinks")) {
+ // Whole-body replacements discard native forms, profile charts,
+ // and listeners already installed on the enhanced profile.
+ let preserveNativePage = ["/modify_user_info.php", "/modify_password.php", "/userinfo.php"].includes(location.pathname);
+ if (UtilityEnabled("ReplaceLinks") && !preserveNativePage) {
document.body.innerHTML = String(document.body.innerHTML).replaceAll(/\[([^<]*)<\/a>\]/g, "$2 ");
}
- if (UtilityEnabled("ReplaceXM")) {
+ if (UtilityEnabled("ReplaceXM") && !preserveNativePage) {
document.body.innerHTML = String(document.body.innerHTML).replaceAll("我", "高老师");
document.body.innerHTML = String(document.body.innerHTML).replaceAll("小明", "高老师");
document.body.innerHTML = String(document.body.innerHTML).replaceAll("下海", "上海");
@@ -3131,6 +3764,7 @@ async function main() {
document.title = String(document.title).replaceAll("小明", "高老师");
}
+ // Bootstrap stylesheet and markup migration.
if (UtilityEnabled("NewBootstrap")) {
// Remove any old Bootstrap/theme stylesheets that the browser's preload
// scanner may have fetched and applied before the document-start
@@ -3503,7 +4137,7 @@ async function main() {
Alert.classList.add("alert-primary");
Alert.role = "alert";
Alert.innerHTML = `欢迎您使用XMOJ增强脚本!点击
- 此处
+ 此处
查看更新日志。`;
Container.appendChild(Alert);
let UtilitiesCard = document.createElement("div");
@@ -3622,7 +4256,7 @@ async function main() {
}, {"ID": "AddAnimation", "Type": "A", "Name": "增加动画"}, {
"ID": "ReplaceYN", "Type": "F", "Name": "题目前状态提示替换为好看的图标"
}, {"ID": "RemoveAlerts", "Type": "D", "Name": "去除多余反复的提示"}, {
- "ID": "Translate", "Type": "F", "Name": "统一使用中文,翻译了部分英文*"
+ "ID": "Translate", "Type": "F", "Name": "翻译部分英文和统一用语*"
}, {
"ID": "ReplaceLinks", "Type": "F", "Name": "将网站中所有以方括号包装的链接替换为按钮"
}, {"ID": "RemoveUseless", "Type": "D", "Name": "删去无法使用的功能*"}, {
@@ -5089,9 +5723,9 @@ async function main() {
PassCheck.click();
}
});
- } else if (location.pathname == "/modifypage.php" && document.querySelector("body > div > div") == null) {
- //页面结构异常(如403/404页面),跳过处理
- } else if (location.pathname == "/modifypage.php") {
+ } else if (IsAccountSettingsPage(location.pathname) && (document.querySelector("body > div > div") == null || (SearchParams.get("ByUserScript") == null && document.querySelector("body > div > div form") == null))) {
+ //页面结构异常或未登录,保留站点提示
+ } else if (IsAccountSettingsPage(location.pathname)) {
if (SearchParams.get("ByUserScript") != null) {
document.title = "XMOJ-Script 更新日志";
if (document.querySelector("body > div > div.mt-3") != null) document.querySelector("body > div > div.mt-3").innerHTML = "";
@@ -5145,388 +5779,72 @@ async function main() {
UpdateDataCardLink.innerText = "查看该版本";
}
});
- } else {
- document.title = "修改账号";
- let Nickname = document.getElementsByName("nick")[0].value;
- let School = document.getElementsByName("school")[0].value;
- let EmailAddress = document.getElementsByName("email")[0].value;
- let CodeforcesAccount = document.getElementsByName("acc_cf")[0].value;
- let AtcoderAccount = document.getElementsByName("acc_atc")[0].value;
- let USACOAccount = document.getElementsByName("acc_usaco")[0].value;
- let LuoguAccount = document.getElementsByName("acc_luogu")[0].value;
- if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
- 修改
-
-
-
- 修改成功
- `;
- document.getElementById("Nickname").value = Nickname;
- document.getElementById("School").value = School;
- document.getElementById("EmailAddress").value = EmailAddress;
- document.getElementById("CodeforcesAccount").value = CodeforcesAccount;
- document.getElementById("AtcoderAccount").value = AtcoderAccount;
- document.getElementById("USACOAccount").value = USACOAccount;
- document.getElementById("LuoguAccount").value = LuoguAccount;
- RequestAPI("GetBadge", {
- "UserID": String(CurrentUsername)
- }, (Response) => {
- if (Response.Success) {
- BadgeRow.style.display = "";
- BadgeContent.value = Response.Data.Content;
- BadgeBackgroundColor.value = Response.Data.BackgroundColor;
- BadgeColor.value = Response.Data.Color;
- let Temp = [];
- for (let i = 0; i < localStorage.length; i++) {
- if (localStorage.key(i).startsWith("UserScript-User-" + CurrentUsername + "-Badge-")) {
- Temp.push(localStorage.key(i));
- }
- }
- for (let i = 0; i < Temp.length; i++) {
- localStorage.removeItem(Temp[i]);
- }
- }
- });
- ModifyInfo.addEventListener("click", async () => {
- ModifyInfo.disabled = true;
- if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "";
- ErrorElement.style.display = "none";
- SuccessElement.style.display = "none";
- //表单元素缺失时不提交,避免用空值覆盖用户信息
- if (document.querySelector("#BadgeContent") == null || document.querySelector("#BadgeBackgroundColor") == null || document.querySelector("#BadgeColor") == null || document.querySelector("#Nickname") == null || document.querySelector("#OldPassword") == null || document.querySelector("#NewPassword") == null || document.querySelector("#NewPasswordAgain") == null || document.querySelector("#School") == null || document.querySelector("#EmailAddress") == null || document.querySelector("#CodeforcesAccount") == null || document.querySelector("#AtcoderAccount") == null || document.querySelector("#USACOAccount") == null || document.querySelector("#LuoguAccount") == null) {
- ModifyInfo.disabled = false;
- if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none";
- ErrorElement.style.display = "block";
- ErrorElement.innerText = "页面加载异常,请刷新后重试";
- return;
- }
- let BadgeContent = document.querySelector("#BadgeContent").value;
- let BadgeBackgroundColor = document.querySelector("#BadgeBackgroundColor").value;
- let BadgeColor = document.querySelector("#BadgeColor").value;
- await new Promise((Resolve) => {
- RequestAPI("EditBadge", {
- "UserID": String(CurrentUsername),
- "Content": String(BadgeContent),
- "BackgroundColor": String(BadgeBackgroundColor),
- "Color": String(BadgeColor)
- }, (Response) => {
- if (Response.Success) {
- Resolve();
- } else {
- ModifyInfo.disabled = false;
- if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none";
- ErrorElement.style.display = "block";
- ErrorElement.innerText = Response.Message;
- }
- });
- });
- let Nickname = document.querySelector("#Nickname").value;
- let OldPassword = document.querySelector("#OldPassword").value;
- let NewPassword = document.querySelector("#NewPassword").value;
- let NewPasswordAgain = document.querySelector("#NewPasswordAgain").value;
- let School = document.querySelector("#School").value;
- let EmailAddress = document.querySelector("#EmailAddress").value;
- let CodeforcesAccount = document.querySelector("#CodeforcesAccount").value;
- let AtcoderAccount = document.querySelector("#AtcoderAccount").value;
- let USACOAccount = document.querySelector("#USACOAccount").value;
- let LuoguAccount = document.querySelector("#LuoguAccount").value;
- await fetch("https://www.xmoj.tech/modify.php", {
- "headers": {
- "content-type": "application/x-www-form-urlencoded"
- },
- "referrer": location.href,
- "method": "POST",
- "body": "nick=" + encodeURIComponent(Nickname) + "&" + "opassword=" + encodeURIComponent(OldPassword) + "&" + "npassword=" + encodeURIComponent(NewPassword) + "&" + "rptpassword=" + encodeURIComponent(NewPasswordAgain) + "&" + "school=" + encodeURIComponent(School) + "&" + "email=" + encodeURIComponent(EmailAddress) + "&" + "acc_cf=" + encodeURIComponent(CodeforcesAccount) + "&" + "acc_atc=" + encodeURIComponent(AtcoderAccount) + "&" + "acc_usaco=" + encodeURIComponent(USACOAccount) + "&" + "acc_luogu=" + encodeURIComponent(LuoguAccount)
- });
- ModifyInfo.disabled = false;
- if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none";
- SuccessElement.style.display = "block";
- });
- if (UtilityEnabled("ExportACCode")) {
- let ExportACCode = document.createElement("button");
- if (document.querySelector("body > div.container > div") != null) document.querySelector("body > div.container > div").appendChild(ExportACCode);
- ExportACCode.innerText = "导出AC代码";
- ExportACCode.className = "btn btn-outline-secondary";
- ExportACCode.addEventListener("click", () => {
- ExportACCode.disabled = true;
- ExportACCode.innerText = "正在导出...";
- let Request = new XMLHttpRequest();
- Request.addEventListener("readystatechange", () => {
- if (Request.readyState == 4) {
- if (Request.status == 200) {
- let Response = Request.responseText;
- let ACCode = Response.split("------------------------------------------------------\r\n");
- let ScriptElement = document.createElement("script");
- ScriptElement.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
- document.head.appendChild(ScriptElement);
- ScriptElement.onload = () => {
- var Zip = new JSZip();
- for (let i = 0; i < ACCode.length; i++) {
- let CurrentCode = ACCode[i];
- if (CurrentCode != "") {
- let lineBreakPos = CurrentCode.search(/[\r\n]/);
- if (lineBreakPos === -1) continue;
- let headerLine = CurrentCode.slice(0, lineBreakPos);
- let digitMatch = headerLine.match(/\d+/);
- if (!digitMatch) continue;
- let CurrentQuestionID = digitMatch[0];
- let bodyStart = lineBreakPos + 1;
- if (CurrentCode[lineBreakPos] === '\r' && CurrentCode[lineBreakPos + 1] === '\n') {
- bodyStart = lineBreakPos + 2;
- }
- CurrentCode = CurrentCode.slice(bodyStart);
- CurrentCode = CurrentCode.replaceAll("\r", "");
- Zip.file(CurrentQuestionID + ".cpp", CurrentCode);
+ }
+ if (SearchParams.get("ByUserScript") == null && UtilityEnabled("ExportACCode")) {
+ let ExportACCode = document.createElement("button");
+ if (document.querySelector("body > div.container > div") != null) document.querySelector("body > div.container > div").appendChild(ExportACCode);
+ ExportACCode.innerText = "导出AC代码";
+ ExportACCode.className = "btn btn-outline-secondary";
+ ExportACCode.addEventListener("click", () => {
+ ExportACCode.disabled = true;
+ ExportACCode.innerText = "正在导出...";
+ let Request = new XMLHttpRequest();
+ Request.addEventListener("readystatechange", () => {
+ if (Request.readyState == 4) {
+ if (Request.status == 200) {
+ let Response = Request.responseText;
+ let ACCode = Response.split("------------------------------------------------------\r\n");
+ let ScriptElement = document.createElement("script");
+ ScriptElement.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
+ document.head.appendChild(ScriptElement);
+ ScriptElement.onload = () => {
+ var Zip = new JSZip();
+ for (let i = 0; i < ACCode.length; i++) {
+ let CurrentCode = ACCode[i];
+ if (CurrentCode != "") {
+ let lineBreakPos = CurrentCode.search(/[\r\n]/);
+ if (lineBreakPos === -1) continue;
+ let headerLine = CurrentCode.slice(0, lineBreakPos);
+ let digitMatch = headerLine.match(/\d+/);
+ if (!digitMatch) continue;
+ let CurrentQuestionID = digitMatch[0];
+ let bodyStart = lineBreakPos + 1;
+ if (CurrentCode[lineBreakPos] === '\r' && CurrentCode[lineBreakPos + 1] === '\n') {
+ bodyStart = lineBreakPos + 2;
}
+ CurrentCode = CurrentCode.slice(bodyStart);
+ CurrentCode = CurrentCode.replaceAll("\r", "");
+ Zip.file(CurrentQuestionID + ".cpp", CurrentCode);
}
- ExportACCode.innerText = "正在生成压缩包……";
- Zip.generateAsync({type: "blob"})
- .then(function (Content) {
- saveAs(Content, "ACCodes.zip");
- ExportACCode.innerText = "AC代码导出成功";
- ExportACCode.disabled = false;
- setTimeout(() => {
- ExportACCode.innerText = "导出AC代码";
- }, 1000);
- });
- };
- } else {
- ExportACCode.disabled = false;
- ExportACCode.innerText = "AC代码导出失败";
- setTimeout(() => {
- ExportACCode.innerText = "导出AC代码";
- }, 1000);
- }
+ }
+ ExportACCode.innerText = "正在生成压缩包……";
+ Zip.generateAsync({type: "blob"})
+ .then(function (Content) {
+ saveAs(Content, "ACCodes.zip");
+ ExportACCode.innerText = "AC代码导出成功";
+ ExportACCode.disabled = false;
+ setTimeout(() => {
+ ExportACCode.innerText = "导出AC代码";
+ }, 1000);
+ });
+ };
+ } else {
+ ExportACCode.disabled = false;
+ ExportACCode.innerText = "AC代码导出失败";
+ setTimeout(() => {
+ ExportACCode.innerText = "导出AC代码";
+ }, 1000);
}
- });
- Request.open("GET", "https://www.xmoj.tech/export_ac_code.php", true);
- Request.send();
+ }
});
- }
+ Request.open("GET", "https://www.xmoj.tech/export_ac_code.php", true);
+ Request.send();
+ });
}
} else if (location.pathname == "/userinfo.php" && document.querySelector("body > div > div") == null) {
//页面结构异常(如403/404页面),跳过处理
} else if (location.pathname == "/userinfo.php") {
- if (SearchParams.get("ByUserScript") === null) {
- if (UtilityEnabled("RemoveUseless")) {
- let Temp = document.getElementById("submission").childNodes;
- for (let i = 0; i < Temp.length; i++) {
- Temp[i].remove();
- }
- }
- if (document.querySelector("body > script:nth-child(5)") != null) eval(document.querySelector("body > script:nth-child(5)").innerHTML);
- if (document.querySelector("#statics > tbody > tr:nth-child(1)") != null) document.querySelector("#statics > tbody > tr:nth-child(1)").remove();
-
- let Temp = (document.querySelector("#statics > tbody") != null) ? document.querySelector("#statics > tbody").children : [];
- for (let i = 0; i < Temp.length; i++) {
- if (Temp[i].children[0] != undefined) {
- if (Temp[i].children[0].innerText == "Statistics") {
- Temp[i].children[0].innerText = "统计";
- } else if (Temp[i].children[0].innerText == "Email:") {
- Temp[i].children[0].innerText = "电子邮箱";
- }
- Temp[i].children[1].removeAttribute("align");
- }
- }
-
- Temp = (document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)") != null) ? document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)").childNodes : [];
- let ACProblems = [];
- for (let i = 0; i < Temp.length; i++) {
- if (Temp[i].tagName == "A" && Temp[i].href.indexOf("problem.php?id=") != -1) {
- ACProblems.push(Number(Temp[i].innerText.trim()));
- }
- }
- if (document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)") != null) document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)").remove();
-
- let UserID, UserNick;
- if (document.querySelector("#statics > caption") != null) [UserID, UserNick] = document.querySelector("#statics > caption").childNodes[0].data.trim().split("--");
- if (document.querySelector("#statics > caption") != null) document.querySelector("#statics > caption").remove();
- document.title = "用户 " + UserID + " 的个人中心";
- let Row = document.createElement("div");
- Row.className = "row";
- let LeftDiv = document.createElement("div");
- LeftDiv.className = "col-md-5";
- Row.appendChild(LeftDiv);
-
- let LeftTopDiv = document.createElement("div");
- LeftTopDiv.className = "row mb-2";
- LeftDiv.appendChild(LeftTopDiv);
- let AvatarContainer = document.createElement("div");
- AvatarContainer.classList.add("col-auto");
- let AvatarElement = document.createElement("img");
- let UserEmailHash = (await GetUserInfo(UserID)).EmailHash;
- if (UserEmailHash == undefined) {
- AvatarElement.src = `https://cravatar.cn/avatar/00000000000000000000000000000000?d=mp&f=y`;
- } else {
- AvatarElement.src = `https://cravatar.cn/avatar/${UserEmailHash}?d=retro`;
- }
- AvatarElement.classList.add("rounded", "me-2");
- AvatarElement.style.height = "120px";
- AvatarContainer.appendChild(AvatarElement);
- LeftTopDiv.appendChild(AvatarContainer);
-
- let UserInfoElement = document.createElement("div");
- UserInfoElement.classList.add("col-auto");
- UserInfoElement.style.lineHeight = "40px";
- UserInfoElement.innerHTML += "用户名:" + escapeHTML(UserID) + " ";
- UserInfoElement.innerHTML += "昵称:" + escapeHTML(UserNick) + " ";
- if (UtilityEnabled("Rating")) {
- UserInfoElement.innerHTML += "评分:" + ((await GetUserInfo(UserID)).Rating) + " ";
- }
- // Create a placeholder for the last online time
- let lastOnlineElement = document.createElement('div');
- lastOnlineElement.innerHTML = "最后在线:加载中... ";
- UserInfoElement.appendChild(lastOnlineElement);
- let BadgeInfo = await GetUserBadge(UserID);
- if (IsAdmin) {
- if (BadgeInfo.Content !== "") {
- let DeleteBadgeButton = document.createElement("button");
- DeleteBadgeButton.className = "btn btn-outline-danger btn-sm";
- DeleteBadgeButton.innerText = "删除标签";
- DeleteBadgeButton.addEventListener("click", async () => {
- if (confirm("您确定要删除此标签吗?")) {
- RequestAPI("DeleteBadge", {
- "UserID": UserID
- }, (Response) => {
- if (UtilityEnabled("DebugMode")) console.log(Response);
- if (Response.Success) {
- let Temp = [];
- for (let i = 0; i < localStorage.length; i++) {
- if (localStorage.key(i).startsWith("UserScript-User-" + UserID + "-Badge-")) {
- Temp.push(localStorage.key(i));
- }
- }
- for (let i = 0; i < Temp.length; i++) {
- localStorage.removeItem(Temp[i]);
- }
- window.location.reload();
- } else {
- SmartAlert(Response.Message);
- }
- });
- }
- });
- UserInfoElement.appendChild(DeleteBadgeButton);
- } else {
- let AddBadgeButton = document.createElement("button");
- AddBadgeButton.className = "btn btn-outline-primary btn-sm";
- AddBadgeButton.innerText = "添加标签";
- AddBadgeButton.addEventListener("click", async () => {
- RequestAPI("NewBadge", {
- "UserID": UserID
- }, (Response) => {
- if (Response.Success) {
- let Temp = [];
- for (let i = 0; i < localStorage.length; i++) {
- if (localStorage.key(i).startsWith("UserScript-User-" + UserID + "-Badge-")) {
- Temp.push(localStorage.key(i));
- }
- }
- for (let i = 0; i < Temp.length; i++) {
- localStorage.removeItem(Temp[i]);
- }
- window.location.reload();
- } else {
- SmartAlert(Response.Message);
- }
- });
- });
- UserInfoElement.appendChild(AddBadgeButton);
- }
- }
- RequestAPI("LastOnline", {"Username": UserID}, (result) => {
- if (result.Success) {
- if (UtilityEnabled("DebugMode")) {
- console.log('lastOnline:' + result.Data.logintime);
- }
- lastOnlineElement.innerHTML = "最后在线:" + GetRelativeTime(result.Data.logintime) + " ";
- } else {
- lastOnlineElement.innerHTML = "最后在线:近三个月内从未 ";
- }
- });
- LeftTopDiv.appendChild(UserInfoElement);
- LeftDiv.appendChild(LeftTopDiv);
-
- let LeftTable = document.querySelector("body > div > div > center > table");
- LeftDiv.appendChild(LeftTable);
- let RightDiv = document.createElement("div");
- RightDiv.className = "col-md-7";
- Row.appendChild(RightDiv);
- RightDiv.innerHTML = "已解决题目 ";
- for (let i = 0; i < ACProblems.length; i++) {
- RightDiv.innerHTML += "" + ACProblems[i] + " ";
- }
- if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").innerHTML = "";
- if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").appendChild(Row);
- } else {
+ if (SearchParams.get("ByUserScript") !== null) {
document.title = "上传标程";
if (document.querySelector("body > div > div.mt-3") != null) document.querySelector("body > div > div.mt-3").innerHTML = `上传标程
@@ -5554,12 +5872,7 @@ async function main() {
return Response.text();
}).then((Response) => {
let ParsedDocument = new DOMParser().parseFromString(Response, "text/html");
- let ScriptData = (ParsedDocument.querySelector("#statics > tbody > tr:nth-child(2) > td:nth-child(3) > script") != null) ? ParsedDocument.querySelector("#statics > tbody > tr:nth-child(2) > td:nth-child(3) > script").innerText : "";
- ScriptData = ScriptData.substr(ScriptData.indexOf("}") + 1).trim();
- ScriptData = ScriptData.split(";");
- for (let i = 0; i < ScriptData.length; i++) {
- ACList.push(Number(ScriptData[i].substring(2, ScriptData[i].indexOf(","))));
- }
+ ACList = GetProfileSolvedProblems(ParsedDocument);
});
RequestAPI("GetStdList", {}, async (Result) => {
if (Result.Success) {
diff --git a/package.json b/package.json
index 834ca508..69334302 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,10 @@
{
"name": "xmoj-script",
- "version": "3.8.2",
+ "version": "3.8.4",
"description": "an improvement script for xmoj.tech",
"main": "AddonScript.js",
"scripts": {
- "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs"
+ "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs tests/chinese-language.test.cjs tests/profile-page.test.cjs"
},
"devDependencies": {
"playwright": "1.59.1"
diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs
new file mode 100644
index 00000000..2530147d
--- /dev/null
+++ b/tests/account-settings.test.cjs
@@ -0,0 +1,358 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const {chromium} = require('playwright');
+
+const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8');
+function Between(start, end) {
+ for (const marker of [start, end]) {
+ assert.equal(source.split(marker).length - 1, 1, 'expected a unique extraction marker: ' + marker);
+ }
+ 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 earlyRedirect = Between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') +
+ Between('// Set to true by the early block', 'const CaptchaSiteKey');
+const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = (');
+const preprocessing = 'window.RunAccountPreprocessing = () => {' + Between(
+ '// Preserve native account listeners during page-wide customization.',
+ '// Bootstrap stylesheet and markup migration.') + '};';
+const handler = 'window.RunAccountPage = async () => { if (false) {' +
+ Between('} else if (IsAccountSettingsPage(location.pathname) &&', '} else if (location.pathname == "/userinfo.php" &&') + '}};';
+const nativeForm = ``;
+const startup = 'window.RunAccountStartup = authenticated => { const logined = authenticated; ' + Between(
+ '// Initialize migrated account tools independently of the legacy navbar/layout handler.',
+ 'let IsAdmin = AdminUserList.indexOf(CurrentUsername)') + '};';
+
+test('account-page migration browser regressions', {timeout: 60000}, async t => {
+ const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined});
+ async function Page(route, content = nativeForm, options = {}) {
+ const page = await browser.newPage();
+ await page.route('**/*', request => request.fulfill({contentType: 'text/html; charset=utf-8', body:
+ options.markup || ``}));
+ await page.goto('https://account-settings.test' + route);
+ await page.evaluate(options => {
+ window.CurrentUsername = options.signedOut ? 'Login' : 'Tester';
+ window.SearchParams = new URL(location.href).searchParams;
+ window.UtilityEnabled = name => ['ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'ExportACCode' && options.export);
+ window.ServerURL = 'https://updates.test';
+ window.GetRelativeTime = () => 'today';
+ window.escapeHTML = value => value;
+ window.GetUserInfo = async () => ({EmailHash: 'hash'});
+ window.apiCalls = [];
+ document.cookie = 'PHPSESSID=fixture; path=/';
+ window.GM_info = {script: {version: 'test'}};
+ window.badgeFailureMode = options.badgeFailureMode;
+ window.badgeLoadFailureMode = options.badgeLoadFailureMode;
+ window.badgeAccessDenied = options.noBadge;
+ window.GM_xmlhttpRequest = request => {
+ const action = new URL(request.url).pathname.slice(1);
+ if (action !== 'SendData') apiCalls.push({action, data: JSON.parse(request.data).Data});
+ if (action === 'EditBadge' || action === 'GetBadge') {
+ const mode = action === 'GetBadge' ? window.badgeLoadFailureMode : window.badgeFailureMode;
+ if (mode === 'pending') {
+ window.pendingBadgeRequest = request;
+ return;
+ }
+ if (['network', 'timeout', 'abort'].includes(mode)) {
+ window.requestTimeout = request.timeout;
+ request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]]();
+ // A late callback must not turn a failed save into success.
+ request.onload({status: 200, responseText: '{"Success":true}'});
+ return;
+ }
+ if (mode === 'throw') throw new Error('Transport initialization failed');
+ if (['invalid-json', 'invalid-response', 'http'].includes(mode)) {
+ request.onload({status: mode === 'http' ? 503 : 200,
+ responseText: mode === 'invalid-json' ? 'Error' : 'null'});
+ return;
+ }
+ }
+ request.onload({status: 200, responseText: JSON.stringify(action === 'GetBadge' ? {
+ Success: !window.badgeAccessDenied, Message: 'Load denied', Data: Object.hasOwn(options, 'badgeData') ? options.badgeData :
+ {Content: 'Badge', BackgroundColor: '#112233', Color: '#ffffff'}
+ } : {Success: !options.badgeFailure, Message: 'Save failed'})});
+ };
+ window.requests = [];
+ window.fetch = async url => {
+ requests.push(url);
+ return {json: async () => ({UpdateHistory: {'1.0.0': {
+ UpdateDate: '2026-01-01', UpdateContents: [{PR: 920, Description: 'Account migration'}]
+ }}})};
+ };
+ window.nativeAccountForm = document.querySelector('form');
+ nativeAccountForm?.addEventListener('submit', event => {
+ event.preventDefault();
+ window.nativeSubmitCount = (window.nativeSubmitCount || 0) + 1;
+ });
+ }, {...options, signedOut: options.signedOut || content === 'Please log in'});
+ await page.addScriptTag({content: api + helpers + preprocessing + handler + startup});
+ await page.evaluate(async authenticated => {
+ RunAccountStartup(authenticated);
+ RunAccountPreprocessing();
+ await RunAccountPage();
+ }, !options.signedOut && content !== 'Please log in');
+ return page;
+ }
+ try {
+ await t.test('preserves migrated fields, CSRF, action and native submit listener', async () => {
+ const page = await Page('/modify_user_info.php', nativeForm, {export: true});
+ assert.equal(await page.evaluate(() => document.querySelector('form') === nativeAccountForm), true);
+ assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php');
+ assert.equal(await page.locator('[name="csrf"]').inputValue(), 'token');
+ assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname');
+ assert.equal(await page.locator('[name="school"]').inputValue(), 'School');
+ assert.equal(await page.locator('[name="email"]').inputValue(), 'user@example.test');
+ assert.equal(await page.locator('input[type="password"]').count(), 0);
+ await page.getByRole('button', {name: 'Save account'}).click();
+ assert.equal(await page.evaluate(() => nativeSubmitCount), 1);
+ assert.equal(await page.getByRole('button', {name: '导出AC代码', exact: true}).count(), 1);
+ assert.deepEqual(await page.evaluate(() => requests), []);
+ await page.close();
+ });
+ for (const [layout, markup] of [
+ ['main without a form', ' '],
+ ['direct body form', nativeForm],
+ ['changed container and navbar', '
']
+ ]) {
+ await t.test('shows and saves the badge editor with ' + layout, async () => {
+ const page = await Page('/modify_user_info.php', nativeForm, {markup});
+ assert.equal(await page.locator('#UserScriptBadgeEditor').isVisible(), true);
+ assert.equal(await page.getByRole('heading', {name: '标签编辑'}).isVisible(), true);
+ assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge');
+ assert.equal(await page.locator('form #UserScriptBadgeEditor').count(), 0);
+ await page.evaluate(() => RunAccountStartup(true));
+ assert.equal(await page.locator('#UserScriptBadgeEditor').count(), 1);
+ await page.locator('#UserScriptBadgeContent').fill('Restored badge');
+ await page.getByRole('button', {name: '修改标签', exact: true}).click();
+ assert.equal(await page.getByRole('status').innerText(), '修改成功');
+ assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge').map(call => call.data.Content)), ['Restored badge']);
+ assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname');
+ await page.close();
+ });
+ }
+ await t.test('uses the signed-in profile if the old login check no longer matches', async () => {
+ const page = await Page('/modify_user_info.php');
+ await page.evaluate(() => {
+ document.getElementById('UserScriptBadgeEditor').remove();
+ RunAccountStartup(false);
+ });
+ assert.equal(await page.locator('#UserScriptBadgeEditor').isVisible(), true);
+ assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge');
+ 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});
+ await page.evaluate(() => {
+ localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale');
+ localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep');
+ });
+ await page.locator('#UserScriptBadgeContent').fill('');
+ await page.getByRole('button', {name: '修改标签', exact: true}).click();
+ assert.deepEqual(await page.evaluate(() => apiCalls[1]), {action: 'EditBadge', data: {
+ UserID: 'Tester', Content: '', BackgroundColor: '#112233', Color: '#ffffff'
+ }});
+ assert.equal(await page.getByRole('status').innerText(), failure ? 'Save failed' : '修改成功');
+ assert.equal(await page.getByRole('button', {name: '修改标签', exact: true}).isEnabled(), true);
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), failure ? 'stale' : null);
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep');
+ assert.equal(await page.evaluate(() => window.nativeSubmitCount || 0), 0);
+ await page.close();
+ });
+ }
+ for (const route of ['/modify_user_info.php']) {
+ const button = 'button:has-text("修改标签")';
+ const content = '#UserScriptBadgeContent';
+ const background = '#UserScriptBadgeBackground';
+ const color = '#UserScriptBadgeColor';
+ for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) {
+ await t.test('normalizes incomplete badge data on ' + route + ': ' + JSON.stringify(badgeData), async () => {
+ const page = await Page(route, nativeForm, {badgeData});
+ assert.equal(await page.locator(content).inputValue(), '');
+ assert.equal(await page.locator(background).inputValue(), '#000000');
+ assert.equal(await page.locator(color).inputValue(), '#ffffff');
+ await page.locator(button).click();
+ assert.deepEqual(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data), {
+ UserID: 'Tester', Content: '', BackgroundColor: '#000000', Color: '#ffffff'
+ });
+ assert.equal(await page.locator(button).isEnabled(), true);
+ await page.close();
+ });
+ }
+ for (const [mode, message] of [
+ ['network', '网络错误,请重试'], ['timeout', '请求超时,请重试'],
+ ['abort', '请求已取消,请重试'], ['throw', '请求失败,请重试'],
+ ['invalid-json', '服务器响应异常,请重试'], ['invalid-response', '服务器响应异常,请重试'],
+ ['http', '请求失败(HTTP 503),请重试']
+ ]) {
+ await t.test('recovers and retries a ' + mode + ' badge failure on ' + route, async () => {
+ const page = await Page(route, nativeForm, {badgeFailureMode: mode});
+ await page.evaluate(() => {
+ localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale');
+ localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep');
+ });
+ await page.locator(content).fill('New badge');
+ await page.locator(button).click();
+ assert.equal(await page.locator(button).isEnabled(), true);
+ assert.equal(await page.locator('[role="status"]').innerText(), message);
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'stale');
+ assert.deepEqual(await page.evaluate(() => requests), []);
+ if (['network', 'timeout', 'abort'].includes(mode)) {
+ assert.equal(await page.evaluate(() => requestTimeout), 15000);
+ }
+ await page.evaluate(() => { window.badgeFailureMode = null; });
+ await page.locator(button).click();
+ assert.equal(await page.locator(button).isEnabled(), true);
+ assert.equal(await page.locator('[role="status"]').innerText(), '修改成功');
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null);
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep');
+ assert.deepEqual(await page.evaluate(() => requests), []);
+ await page.close();
+ });
+ }
+ }
+ await t.test('badge access denial leaves the native form usable', async () => {
+ const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true});
+ 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();
+ });
+ for (const route of ['/modify_user_info.php']) {
+ const content = '#UserScriptBadgeContent';
+ const save = 'button:has-text("修改标签")';
+ const status = '[role="status"]';
+ for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'invalid-response', 'http', 'denied']) {
+ await t.test('protects existing badges and retries a ' + mode + ' load failure on ' + route, async () => {
+ const page = await Page(route, nativeForm,
+ {badgeLoadFailureMode: mode, noBadge: mode === 'denied'});
+ assert.match(await page.locator(status).innerText(), /标签加载失败:/);
+ assert.equal(await page.locator(content).isDisabled(), true);
+ assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isVisible(), true);
+ await page.evaluate(() => localStorage.setItem('UserScript-User-Tester-Badge-Content', 'existing badge'));
+ assert.equal(await page.locator(save).isDisabled(), true);
+ await page.locator(save).dispatchEvent('click');
+ await page.getByRole('button', {name: 'Save account'}).click();
+ assert.equal(await page.evaluate(() => nativeSubmitCount), 1);
+ assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []);
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'existing badge');
+ await page.evaluate(() => {
+ window.badgeLoadFailureMode = null;
+ window.badgeAccessDenied = false;
+ });
+ await page.getByRole('button', {name: '重试加载标签', exact: true}).click();
+ assert.equal(await page.locator(content).isEnabled(), true);
+ assert.equal(await page.locator(content).inputValue(), 'Badge');
+ assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isHidden(), true);
+ await page.locator(content).fill('Recovered badge');
+ await page.locator(save).click();
+ assert.equal(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data.Content), 'Recovered badge');
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null);
+ await page.close();
+ });
+ }
+ await t.test('blocks badge writes while the initial load is pending on ' + route, async () => {
+ const page = await Page(route, nativeForm, {badgeLoadFailureMode: 'pending'});
+ assert.match(await page.locator(status).innerText(), /正在加载标签/);
+ assert.equal(await page.locator(content).isDisabled(), true);
+ await page.locator(save).dispatchEvent('click');
+ assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []);
+ await page.evaluate(() => pendingBadgeRequest.onload({status: 200, responseText: JSON.stringify({
+ Success: true, Data: {Content: 'Existing badge', BackgroundColor: '#112233', Color: '#ffffff'}
+ })}));
+ assert.equal(await page.locator(content).isEnabled(), true);
+ assert.equal(await page.locator(content).inputValue(), 'Existing badge');
+ await page.close();
+ });
+ }
+ for (const route of ['/modify_user_info.php']) {
+ await t.test('renders changelog on ' + route + ' even without an account form', async () => {
+ const page = await Page(route + '?ByUserScript=1', 'Please log in', {export: true});
+ assert.equal(await page.title(), 'XMOJ-Script 更新日志');
+ assert.equal(await page.locator('.card-title').innerText(), '1.0.0');
+ assert.match(await page.locator('.list-group-item').innerText(), /Account migration/);
+ assert.deepEqual(await page.evaluate(() => apiCalls), []);
+ assert.equal(await page.locator('button').count(), 0);
+ await page.close();
+ });
+ await t.test('leaves login and error messages intact on ' + route, async () => {
+ const page = await Page(route, 'Please log in', {export: true});
+ assert.equal(await page.locator('.mt-3').innerText(), 'Please log in');
+ assert.deepEqual(await page.evaluate(() => apiCalls), []);
+ await page.evaluate(async () => {
+ document.body.innerHTML = '403 ';
+ await RunAccountPage();
+ });
+ assert.equal(await page.locator('h1').innerText(), '403');
+ await page.close();
+ });
+ }
+ await t.test('redirects defunct account links to the current page preserving the query and fragment', async () => {
+ const page = await browser.newPage();
+ await page.route('**/*', request => request.fulfill({contentType: 'text/html', body: nativeForm}));
+ // Run the actual document-start block, rather than redirecting from the test.
+ await page.addInitScript({content: 'function GetContestWebRedirect() { return null; }\nfunction IsContestWebApp() { return false; }\nlocalStorage.setItem("UserScript-Setting-NewBootstrap", "false");\n' + earlyRedirect});
+ await page.goto('https://account-settings.test/modifypage.php?ByUserScript=1#latest');
+ await page.waitForURL('https://account-settings.test/modify_user_info.php?ByUserScript=1#latest');
+ assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php');
+ await page.close();
+ });
+ await t.test('leaves the separate password form intact', async () => {
+ const page = await Page('/modify_password.php', '', {export: true});
+ assert.equal(await page.evaluate(() => document.querySelector('form') === nativeAccountForm), true);
+ assert.deepEqual(await page.evaluate(() => apiCalls), []);
+ assert.equal(await page.locator('button').count(), 0);
+ await page.close();
+ });
+ for (const [label, route] of [['修改帐号', '/modify_user_info.php'], ['插件更新日志', '/modify_user_info.php?ByUserScript=1']]) {
+ await t.test('user menu sends ' + label + ' to the migrated route', async () => {
+ const page = await Page('/index.php');
+ await page.evaluate(() => CreateUserMenuItems().forEach(item => document.body.appendChild(item)));
+ await Promise.all([page.waitForURL('https://www.xmoj.tech' + route), page.getByText(label, {exact: true}).click()]);
+ assert.equal(page.url(), 'https://www.xmoj.tech' + route);
+ await page.close();
+ });
+ }
+ } finally {
+ await browser.close();
+ }
+});
+
+test('ordinary API requests retain the stashed timeout and log failures once', () => {
+ for (const event of ['onerror', 'ontimeout', 'onabort']) {
+ let request;
+ const errors = [], responses = [];
+ const context = vm.createContext({
+ document: {cookie: 'PHPSESSID=fixture'}, CurrentUsername: 'Viewer',
+ GM_info: {script: {version: 'test'}}, UtilityEnabled: () => false,
+ GM_xmlhttpRequest: options => { request = options; },
+ console: {error: message => errors.push(message)},
+ saveResponse: response => responses.push(response)
+ });
+ vm.runInContext(api + '\nRequestAPI("GetUserInfo", {}, saveResponse);', context);
+ assert.equal(request.timeout, 30000);
+ request[event]();
+ request[event]();
+ request.onload({status: 200, responseText: '{"Success":true}'});
+ assert.equal(errors.length, 1, 'duplicate transport events must log only once');
+ assert.match(errors[0], /Request GetUserInfo/);
+ assert.equal(responses.length, 0, 'a late response must not revive a failed request');
+ }
+});
+
+test('current account tools initialize before the monolithic page handler', () => {
+ const call = source.indexOf('InitializeAccountFeatures(logined');
+ const main = source.indexOf('await main();');
+ assert.ok(call >= 0);
+ assert.ok(main >= 0);
+ assert.ok(call < main);
+ assert.match(source, /const AccountRedirect = GetAccountSettingsRedirect\(\);/);
+});
diff --git a/tests/chinese-language.test.cjs b/tests/chinese-language.test.cjs
new file mode 100644
index 00000000..3db28d04
--- /dev/null
+++ b/tests/chinese-language.test.cjs
@@ -0,0 +1,190 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const {chromium} = require('playwright');
+const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8');
+function Between(start, end) {
+ for (const marker of [start, end]) assert.equal(source.split(marker).length - 1, 1, 'expected unique marker: ' + marker);
+ assert.ok(source.indexOf(start) < source.indexOf(end));
+ return source.slice(source.indexOf(start), source.indexOf(end));
+}
+const language = Between('function InitializeChineseLanguage(', '// The /web application owns its DOM.');
+const route = Between('function IsContestWebApp(', 'function GetContestRoute(');
+
+// Check reload behavior without navigating a real page away from its assertions.
+function Context({cookie = '', pathname = '/problem.php', english = false, fail = false} = {}) {
+ const storage = new Map();
+ const requests = [];
+ const nodes = [];
+ let reloads = 0;
+ const location = new URL('https://www.xmoj.tech' + pathname + '?id=1000#samples');
+ location.reload = () => { reloads++; };
+ const scope = vm.createContext({
+ location, console: {error() {}}, RevealPage() {}, AbortController, setTimeout, clearTimeout,
+ sessionStorage: {getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key)},
+ fetch: async (url, options) => {
+ requests.push({url, options});
+ if (fail) throw new Error('Network failed');
+ return {ok: true};
+ },
+ document: {
+ cookie, documentElement: {}, head: {appendChild: node => nodes.push(node)}, body: {prepend: node => nodes.push(node)},
+ getElementById: id => nodes.find(node => node.id === id),
+ querySelectorAll: () => [], querySelector: () => english ? {} : null,
+ createElement: () => ({appendChild() {}, addEventListener() {}})
+ }
+ });
+ vm.runInContext(route + language, scope);
+ return {scope, requests, storage, nodes, reloads: () => reloads};
+}
+
+test('forces the web cookie before requests and reloads an existing English app once', async () => {
+ const {scope, requests, reloads} = Context({cookie: 'PHPSESSID=fixture; XMOJ_LANG=en', pathname: '/web/contest/123/A'});
+ assert.match(scope.document.cookie, /^XMOJ_LANG=zh; path=/);
+ assert.equal(await scope.EnsureChinesePage(), true);
+ assert.equal(reloads(), 1);
+ assert.equal(scope.location.href, 'https://www.xmoj.tech/web/contest/123/A?id=1000#samples');
+ assert.equal(requests.length, 0);
+ assert.equal(await scope.EnsureChinesePage(), false);
+ assert.equal(reloads(), 1, 'refusing the preference must not create a reload loop');
+});
+
+test('classic English pages do not call the broken language endpoint or reload', async () => {
+ const {scope, requests, reloads} = Context({english: true});
+ assert.equal(await scope.EnsureChinesePage(), false);
+ assert.equal(scope.document.documentElement.lang, 'zh-CN');
+ assert.equal(requests.length, 0);
+ assert.equal(reloads(), 0);
+});
+
+test('Chinese classic pages do not reload merely because html declares lang=en', async () => {
+ const {scope, requests, reloads, storage} = Context();
+ scope.document.documentElement.lang = 'en';
+ storage.set('UserScript-ChineseLanguageReload', scope.location.pathname + scope.location.search + scope.location.hash);
+ assert.equal(await scope.EnsureChinesePage(), false);
+ assert.equal(scope.document.documentElement.lang, 'zh-CN');
+ assert.equal(requests.length, 0);
+ assert.equal(reloads(), 0);
+ assert.equal(storage.size, 0);
+});
+
+test('an English web app refusing Chinese shows a retry message without reloading again', async () => {
+ const {scope, reloads, nodes, storage, requests} = Context({cookie: 'XMOJ_LANG=en', pathname: '/web/contest/123/A'});
+ storage.set('UserScript-ChineseLanguageReload', scope.location.pathname + scope.location.search + scope.location.hash);
+ assert.equal(await scope.EnsureChinesePage(), false);
+ assert.equal(reloads(), 0);
+ assert.equal(requests.length, 0);
+ assert.match(nodes.find(node => node.role === 'alert').textContent, /切换中文失败/);
+});
+
+test('hides classic and Vue language selectors while preserving forms and Chinese content', {timeout: 60000}, async () => {
+ const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined});
+ const page = await browser.newPage();
+ try {
+ await page.route('**/*', request => request.fulfill({contentType: 'text/html; charset=utf-8', body: `
+ English
+ 中文
+
+ 中文题面 English statement
+ English 中文
+
English content
+ `}));
+ await page.goto('https://chinese-language.test/problem.php');
+ await page.evaluate(() => {
+ window.accountForm = document.querySelector('form');
+ window.submits = 0;
+ accountForm.addEventListener('submit', event => { event.preventDefault(); submits++; });
+ document.querySelector('.xmoj-lang-switch button:last-child').addEventListener('click', event => {
+ event.target.classList.add('hidden');
+ document.getElementById('statement').textContent = '中文内容';
+ window.chineseClicks = (window.chineseClicks || 0) + 1;
+ });
+ });
+ await page.addScriptTag({content: route + language});
+ await page.evaluate(() => { EnforceChineseView(); EnforceChineseView(); });
+ assert.equal(await page.locator('#lang_cn_to_en').isHidden(), true);
+ assert.equal(await page.locator('#lang_en_to_cn').isHidden(), true);
+ assert.equal(await page.locator('#xmoj-navbar li:last-child a').isHidden(), true);
+ assert.equal(await page.locator('#xmoj-navbar .dropdown-toggle').isVisible(), true, 'a username named English must remain visible');
+ assert.equal(await page.locator('.xmoj-lang-switch').isHidden(), true);
+ assert.equal(await page.locator('.lang_cn').isVisible(), true);
+ assert.equal(await page.locator('.lang_en').isHidden(), true);
+ assert.equal(await page.locator('#statement').innerText(), '中文内容');
+ assert.equal(await page.evaluate(() => chineseClicks), 1);
+ assert.equal(await page.evaluate(() => document.querySelector('form') === accountForm), true);
+ await page.getByRole('button', {name: 'Save account'}).click();
+ assert.equal(await page.evaluate(() => submits), 1);
+ assert.equal(await page.locator('#UserScript-ChineseLanguage').count(), 1);
+ await page.evaluate(() => {
+ const item = document.createElement('li');
+ item.innerHTML = '中文 ';
+ document.querySelector('#xmoj-navbar ul').appendChild(item);
+ EnforceChineseView(document.getElementById('app'));
+ });
+ assert.equal(await page.locator('#xmoj-navbar li:last-child a').isHidden(), true);
+ } finally {
+ await browser.close();
+ }
+});
+
+test('hides the native account language group and submits Chinese with other account fields intact', {timeout: 60000}, async t => {
+ const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined});
+ try {
+ for (const [layout, fields, value] of [
+ ['radio row', '界面语言 ', 'cn'],
+ ['fieldset with numeric values', '界面语言 中文 English ', '0'],
+ ['direct form children', '界面语言 中文 English ', 'zh']
+ ]) {
+ await t.test(layout, async () => {
+ const page = await browser.newPage();
+ await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: ''}));
+ await page.goto('https://chinese-language.test/modify_user_info.php');
+ await page.evaluate(() => {
+ window.accountForm = document.querySelector('form');
+ window.nativeSubmits = 0;
+ accountForm.addEventListener('submit', event => {
+ event.preventDefault();
+ nativeSubmits++;
+ window.submittedData = Object.fromEntries(new FormData(accountForm));
+ });
+ });
+ await page.addScriptTag({content: route + language});
+ await page.evaluate(() => { EnforceChineseView(); EnforceChineseView(); });
+ assert.equal(await page.getByText('界面语言', {exact: true}).isHidden(), true);
+ assert.equal(await page.getByText('English', {exact: true}).isHidden(), true);
+ assert.equal(await page.getByText('中文', {exact: true}).isHidden(), true);
+ assert.equal(await page.locator('input[type="radio"]:checked').inputValue(), value);
+ assert.equal(await page.locator('input[type="radio"]').evaluateAll(inputs => inputs.every(input => !input.disabled)), true);
+ assert.equal(await page.locator('[name="nick"]').isVisible(), true);
+ assert.equal(await page.getByRole('button', {name: '提交'}).isVisible(), true);
+ assert.equal(await page.evaluate(() => document.querySelector('form') === accountForm), true);
+ // Native submit handlers and FormData must receive Chinese even if
+ // another script reselects English after initialization.
+ await page.evaluate(() => { document.querySelector('input[value="en"], input[value="1"]').checked = true; });
+ await page.getByRole('button', {name: '提交'}).click();
+ assert.equal(await page.evaluate(() => nativeSubmits), 1);
+ assert.deepEqual(await page.evaluate(() => submittedData), {nick: 'boomzero', csrf: 'token', lang: value});
+ assert.equal(await page.evaluate(() => {
+ document.querySelector('input[value="en"], input[value="1"]').checked = true;
+ return new FormData(accountForm).get('lang');
+ }), value);
+ await page.close();
+ });
+ }
+ } finally { await browser.close(); }
+});
+
+test('startup enforces Chinese before either UI initializer and Vue refreshes reapply it', () => {
+ const startup = Between('(async () => {\nif (GetContestWebRedirect()) return;', '//otherwise CurrentUsername might be undefined');
+ const enforce = startup.indexOf('await EnsureChinesePage()');
+ const initialize = startup.indexOf('await InitializeContestWebApp()');
+ assert.ok(enforce >= 0, 'Chinese initialization must exist');
+ assert.ok(initialize >= 0, 'Vue initialization must exist');
+ assert.ok(enforce < initialize);
+ const enhance = Between('function Enhance() {', 'function ScheduleEnhance() {');
+ assert.match(enhance, /EnforceChineseView\(root\)/);
+});
diff --git a/tests/contest-web.test.cjs b/tests/contest-web.test.cjs
index 9528d2bb..7b7d94f1 100644
--- a/tests/contest-web.test.cjs
+++ b/tests/contest-web.test.cjs
@@ -178,7 +178,8 @@ test('the load-time hide keeps innerText readable for the page handlers', () =>
});
test('web and classic pages load the same monochrome fonts before either Bootstrap path', () => {
- const early = between('// Set to true by the early block', 'const CaptchaSiteKey');
+ const early = between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') +
+ between('// Set to true by the early block', 'const CaptchaSiteKey');
const fontURLs = new Set();
for (const pathname of ['/web/contest', '/problem.php?id=1000']) {
for (const cachedBootstrap of [false, true]) {
diff --git a/tests/fixtures/profile.html b/tests/fixtures/profile.html
new file mode 100644
index 00000000..6c3812ab
--- /dev/null
+++ b/tests/fixtures/profile.html
@@ -0,0 +1,35 @@
+
+Viewer
+
+
+ProfileTarget--昵称--后缀短消息
+Solved Problems List
+解决 2
+Activity chart
+提交 20
+正确 8
+答案错误 12
+Statistics Old overlapping legend
+Email: profile@example.test
+
+
+
diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs
index 2cdb28fe..4a581ed7 100644
--- a/tests/navbar-styler.test.cjs
+++ b/tests/navbar-styler.test.cjs
@@ -264,3 +264,79 @@ test('navbar reserves content space before Bootstrap CSS loads and restores its
await browser.close();
}
});
+
+test('browser cosmetic filtering does not hide the navbar spacer', {timeout: 60000}, async () => {
+ const start = 'let navbarStyler = null;';
+ const end = '// Wrapped in an async IIFE';
+ const navbarSource = source.slice(source.indexOf(start), source.indexOf(end));
+ const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined});
+ const page = await browser.newPage();
+ try {
+ // The reporter's video shows this browser-injected cosmetic rule. A block
+ // spacer whose inline style begins with these declarations is hidden.
+ await page.setContent(`Navbar Page content
`);
+ await page.evaluate(() => { window.UtilityEnabled = () => true; });
+ await page.addScriptTag({content: navbarSource + '\nUpdateNavbarStyler();'});
+ const Measure = () => page.evaluate(() => {
+ const spacer = document.getElementById('navbar-spacer');
+ return {
+ display: getComputedStyle(spacer).display,
+ height: spacer.getBoundingClientRect().height,
+ filtered: spacer.matches('body > [style^="display: block; width: 100%; height:"]:empty'),
+ navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom,
+ contentTop: document.querySelector('main').getBoundingClientRect().top
+ };
+ });
+ for (const height of [64, 96]) {
+ await page.evaluate(height => { document.querySelector('nav').style.height = height + 'px'; UpdateNavbarStyler(); }, height);
+ const result = await Measure();
+ assert.equal(result.display, 'block');
+ assert.equal(result.filtered, false, 'our spacer must not match the recorded browser filter');
+ assert.equal(result.height, height + 24);
+ assert.ok(result.contentTop >= result.navbarBottom, 'the first content must remain below the navbar');
+ }
+ // Switching navbars releases and recreates the spacer and its stylesheet.
+ await page.evaluate(() => {
+ document.querySelector('nav').outerHTML = 'Replacement ';
+ UpdateNavbarStyler();
+ });
+ assert.equal((await Measure()).height, 72);
+ await page.evaluate(() => { window.UtilityEnabled = () => false; UpdateNavbarStyler(); });
+ assert.equal(await page.locator('#navbar-spacer').count(), 0);
+ assert.equal(await page.locator('head style').count(), 1, 'remove the owned styles but keep the browser-rule fixture');
+ // An existing spacer from an earlier initializer can carry the old inline
+ // fingerprint. Borrow it without losing its styles or page-added changes.
+ await page.evaluate(() => {
+ document.body.innerHTML = 'Navbar Page content
';
+ window.originalSpacer = document.getElementById('navbar-spacer');
+ window.UtilityEnabled = () => true;
+ });
+ assert.equal((await Measure()).display, 'none', 'the old borrowed spacer must reproduce the filter');
+ await page.evaluate(() => UpdateNavbarStyler());
+ const borrowed = await Measure();
+ assert.equal(borrowed.display, 'block');
+ assert.equal(borrowed.filtered, false);
+ assert.equal(borrowed.height, 88);
+ assert.ok(borrowed.contentTop >= borrowed.navbarBottom);
+ await page.evaluate(() => {
+ document.getElementById('navbar-spacer').style.color = 'red';
+ window.UtilityEnabled = () => false;
+ UpdateNavbarStyler();
+ });
+ assert.deepEqual(await page.locator('#navbar-spacer').evaluate(node => ({
+ sameNode: node === window.originalSpacer,
+ display: node.style.display, width: node.style.width, height: node.style.height,
+ color: node.style.color,
+ originalOrder: node.getAttribute('style').startsWith('display: block; width: 100%; height:'),
+ filteredAgain: getComputedStyle(node).display === 'none'
+ })), {sameNode: true, display: 'block', width: '100%', height: '9px', color: 'red', originalOrder: true, filteredAgain: true});
+ assert.equal(await page.locator('head style').count(), 1);
+ } finally {
+ await browser.close();
+ }
+});
diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs
new file mode 100644
index 00000000..13674a5c
--- /dev/null
+++ b/tests/profile-page.test.cjs
@@ -0,0 +1,306 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const {chromium} = require('playwright');
+const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8');
+const fixture = fs.readFileSync(path.join(__dirname, 'fixtures/profile.html'), 'utf8');
+function Between(start, end) {
+ for (const marker of [start, end]) assert.equal(source.split(marker).length - 1, 1, 'unique extraction marker: ' + marker);
+ assert.ok(source.indexOf(start) < source.indexOf(end));
+ return source.slice(source.indexOf(start), source.indexOf(end));
+}
+const profile = Between('function GetProfileSolvedProblems(', 'function IsAccountSettingsPage(');
+const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = (');
+const relativeTime = Between('let GetRelativeTime = (', 'function compareVersions(');
+const preprocessing = 'window.RunProfilePreprocessing = () => {' + Between(
+ '// Preserve native account listeners during page-wide customization.',
+ '// Bootstrap stylesheet and markup migration.') + '};';
+
+test('profile page browser regressions', {timeout: 60000}, async t => {
+ const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined});
+ async function Page(options = {}) {
+ const page = await browser.newPage();
+ await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: options.html ?? fixture}));
+ await page.goto('https://profile.test/userinfo.php?user=ProfileTarget' + (options.upload ? '&ByUserScript=1' : ''));
+ await page.evaluate(options => {
+ window.CurrentUsername = 'Viewer';
+ window.GM_info = {script: {version: 'test'}};
+ document.cookie = 'PHPSESSID=fixture; path=/';
+ window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity !== false);
+ window.CryptoJS = {MD5: email => { window.avatarEmail = email; return {toString: () => '123456789012345678901234567890abcf'}; }};
+ window.GetUserInfo = window.GetUserBadge = () => { throw new Error('Profile must not wait for these APIs'); };
+ window.onlineTimestamp = options.onlineTimestamp ?? 1234;
+ window.confirm = () => true;
+ window.apiCalls = [];
+ window.requests = {};
+ window.badgeMode = options.badgeMode;
+ window.onlineMode = options.onlineMode;
+ window.badgeData = Object.hasOwn(options, 'badgeData') ? options.badgeData : {Content: '省一', BackgroundColor: '#112233', Color: '#ffffff'};
+ window.actionMode = options.actionMode;
+ window.GM_xmlhttpRequest = request => {
+ const action = new URL(request.url).pathname.slice(1);
+ const data = JSON.parse(request.data).Data;
+ if (action === 'SendData') return;
+ apiCalls.push({action, data, timeout: request.timeout});
+ requests[action] = request;
+ const mode = action === 'GetBadge' ? badgeMode : action === 'LastOnline' ? onlineMode : actionMode;
+ if (mode === 'pending') return;
+ if (mode === 'throw') throw new Error('Transport failure');
+ if (['network', 'timeout', 'abort'].includes(mode)) {
+ request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]]();
+ request.onload({status: 200, responseText: '{"Success":true,"Data":{"Content":"late"}}'});
+ return;
+ }
+ if (mode === 'invalid-json' || mode === 'http') {
+ 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;
+ request.onload({status: 200, responseText: JSON.stringify({Success: mode !== 'denied', Message: 'Denied',
+ Data: action === 'GetBadge' ? badgeData : action === 'LastOnline' ? {logintime: onlineTimestamp} : {}})});
+ };
+ window.nativeStats = document.getElementById('statics');
+ window.nativePie = document.getElementById('PieDiv');
+ window.nativeActivity = document.getElementById('submission');
+ window.nativeCountLink = document.querySelector('a[href*="problem_id=1000"]');
+ window.pieClicks = window.countClicks = 0;
+ nativePie?.addEventListener('click', () => { pieClicks++; });
+ nativeCountLink?.addEventListener('click', event => { event.preventDefault(); countClicks++; });
+ }, options);
+ await page.addScriptTag({content: api + relativeTime + profile + preprocessing});
+ await page.evaluate(isAdmin => {
+ InitializeUserProfile(isAdmin);
+ RunProfilePreprocessing();
+ }, !!options.admin);
+ return page;
+ }
+ const AssertCore = async page => {
+ assert.equal(await page.locator('#UserScriptProfile').isVisible(), true);
+ assert.equal(await page.title(), '用户 ProfileTarget 的个人中心');
+ assert.equal(await page.getByText('用户名:ProfileTarget', {exact: true}).isVisible(), true);
+ assert.equal(await page.getByText('昵称:昵称--后缀', {exact: true}).isVisible(), true);
+ assert.equal(await page.getByText('评分:400', {exact: true}).isVisible(), true);
+ assert.equal(await page.locator('#UserScriptProfile img').isVisible(), true);
+ assert.match(await page.locator('#UserScriptProfile img').getAttribute('src'), /123456789012345678901234567890abcf/);
+ assert.equal(await page.evaluate(() => avatarEmail), 'profile@example.test');
+ assert.deepEqual(await page.locator('#UserScriptProfileSolved a[href*="problem.php"]').allTextContents(), ['1000 ', '1010 ']);
+ assert.equal(await page.getByRole('link', {name: '短消息', exact: true}).getAttribute('href'), 'mail.php?to_user=ProfileTarget');
+ assert.equal(await page.locator('#statics tr').count(), 6);
+ assert.equal(await page.locator('#statics a[href*="jresult=4"]').allTextContents().then(values => values.join(',')), '2,8');
+ assert.equal(await page.evaluate(() => document.getElementById('statics') === nativeStats), true);
+ assert.equal(await page.evaluate(() => document.getElementById('PieDiv') === nativePie), true);
+ assert.equal(await page.evaluate(() => document.querySelector('a[href*="problem_id=1000"]') === nativeCountLink), true);
+ assert.equal(await page.evaluate(() => nativeProfileRuns), 1, 'never re-evaluate native document.write scripts');
+ };
+ try {
+ await t.test('restores identity, rating, badge, last online, solved links and statistics', async () => {
+ const page = await Page();
+ await AssertCore(page);
+ assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一');
+ assert.match(await page.locator('#UserScriptProfileLastOnline').innerText(), /^最后在线:\d+年前$/);
+ assert.deepEqual(await page.evaluate(() => apiCalls), [
+ {action: 'GetBadge', data: {UserID: 'ProfileTarget'}, timeout: 15000},
+ {action: 'LastOnline', data: {Username: 'ProfileTarget'}, timeout: 15000}
+ ]);
+ assert.equal(await page.getByRole('button', {name: '添加标签'}).count(), 0);
+ await page.getByRole('link', {name: '7', exact: true}).click();
+ await page.locator('#PieDiv').click();
+ assert.deepEqual(await page.evaluate(() => [countClicks, pieClicks]), [1, 1]);
+ await page.evaluate(() => InitializeUserProfile());
+ assert.equal(await page.locator('#UserScriptProfile').count(), 1);
+ assert.equal(await page.evaluate(() => apiCalls.length), 2);
+ await page.close();
+ });
+ await t.test('renders core content immediately while optional requests are pending', async () => {
+ const page = await Page({badgeMode: 'pending', onlineMode: 'pending'});
+ await AssertCore(page);
+ assert.match(await page.getByRole('status').innerText(), /正在加载标签/);
+ await page.evaluate(() => requests.LastOnline.ontimeout());
+ assert.equal(await page.locator('#UserScriptProfileLastOnline').innerText(), '最后在线:暂不可用');
+ await page.close();
+ });
+ await t.test('renders relative timestamps as readable text with the formatter tooltip', async () => {
+ const timestamp = Date.now() - 90 * 60 * 1000;
+ const page = await Page({onlineTimestamp: timestamp});
+ const lastOnline = page.locator('#UserScriptProfileLastOnline');
+ assert.equal(await lastOnline.innerText(), '最后在线:1小时前');
+ assert.equal(await lastOnline.locator('span').count(), 1);
+ assert.equal(await lastOnline.locator('span').getAttribute('title'), await page.evaluate(value => new Date(value).toLocaleString(), timestamp));
+ await page.close();
+ });
+ 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});
+ await AssertCore(page);
+ assert.match(await page.getByRole('status').innerText(), /标签暂不可用/);
+ assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isVisible(), true);
+ 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 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});
+ await AssertCore(page);
+ assert.equal(await page.locator('#UserScriptProfileBadge').isHidden(), true);
+ assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true);
+ await page.close();
+ });
+ }
+ await t.test('admin badge actions target the viewed user and recover from failures', async () => {
+ const page = await Page({admin: true, actionMode: 'network'});
+ await page.evaluate(() => {
+ localStorage.setItem('UserScript-User-ProfileTarget-Badge-Content', 'cached');
+ localStorage.setItem('UserScript-User-Viewer-Badge-Content', 'keep');
+ });
+ await page.getByRole('button', {name: '删除标签'}).click();
+ assert.equal(await page.getByRole('button', {name: '删除标签'}).isEnabled(), true);
+ assert.equal(await page.getByRole('status').innerText(), '网络错误,请重试');
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-ProfileTarget-Badge-Content')), 'cached');
+ await page.evaluate(() => { window.actionMode = null; });
+ await page.getByRole('button', {name: '删除标签'}).click();
+ assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true);
+ await page.getByRole('button', {name: '添加标签'}).click();
+ assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), 'New badge');
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-ProfileTarget-Badge-Content')), null);
+ assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Viewer-Badge-Content')), 'keep');
+ assert.equal(await page.evaluate(() => apiCalls.filter(call => ['NewBadge', 'DeleteBadge'].includes(call.action)).every(call => call.data.UserID === 'ProfileTarget')), true);
+ await page.close();
+ });
+ await t.test('uses query identity with no caption and tolerates missing email, history and header rows', async () => {
+ const html = ' ';
+ const page = await Page({html});
+ assert.equal(await page.getByText('用户名:ProfileTarget', {exact: true}).isVisible(), true);
+ assert.equal(await page.getByText('评分:0', {exact: true}).isVisible(), true);
+ assert.equal(await page.locator('#statics tr').count(), 2);
+ assert.equal(await page.locator('#UserScriptProfileSolved a').count(), 0);
+ assert.match(await page.locator('#UserScriptProfile img').getAttribute('src'), /d=mp/);
+ await page.close();
+ });
+ for (const removeActivity of [false, true]) {
+ await t.test('draws submission history without Flot with cleanup=' + removeActivity, async () => {
+ const page = await Page({removeActivity});
+ const graph = page.getByRole('img', {name: '提交与正确数量随时间的变化'});
+ assert.equal(await graph.isVisible(), true);
+ assert.equal(await page.evaluate(() => document.getElementById('submission') === nativeActivity), true);
+ assert.equal(await page.locator('#submission canvas').count(), 0);
+ assert.equal(await page.evaluate(() => window.jQuery), undefined);
+ assert.equal(await page.evaluate(() => window.nativeHistoryRuns), undefined, 'never execute the broken native graph script');
+ assert.deepEqual(await page.evaluate(() => GetProfileActivityData()), [
+ [[1704067200000, 20], [1704153600000, 5], [1704240000000, 12]],
+ [[1704067200000, 8], [1704153600000, 2], [1704240000000, 4]]
+ ]);
+ assert.equal(await graph.locator('path[data-series="submitted"]').count(), 1);
+ assert.equal(await graph.locator('circle[data-series="submitted"]').count(), 3);
+ assert.equal(await graph.locator('rect[data-series="accepted"]').count(), 3);
+ assert.equal(await graph.locator('[data-panel]').count(), 0, 'both series belong on one graph');
+ assert.deepEqual(await graph.locator('[data-layer]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layer'))), ['accepted', 'submitted'], 'accepted bars must paint behind the submission line');
+ assert.equal(await graph.locator('rect').evaluateAll(nodes => nodes.every(node => Number(node.getAttribute('fill-opacity')) < 1)), true, 'overlapping bars must be translucent');
+ assert.ok(Number(await graph.locator('path').getAttribute('stroke-width')) < 1.5, 'dense submissions use a thin line');
+ assert.ok(Number(await graph.locator('path').getAttribute('stroke-opacity')) < 1, 'the line must allow the underlying bars to show through');
+ assert.equal(await graph.locator('circle').evaluateAll(nodes => nodes.every(node => node.getAttribute('fill') === 'transparent')), true, 'dense dates must not paint overlapping dot markers');
+ const timeline = await graph.evaluate(svg => [...svg.querySelectorAll('circle')].map((dot, index) => {
+ const bar = svg.querySelectorAll('rect')[index];
+ return Math.abs(Number(dot.getAttribute('cx')) - Number(bar.getAttribute('x')) - Number(bar.getAttribute('width')) / 2);
+ }));
+ assert.ok(timeline.every(offset => offset < 0.00001), 'matching dates must align on the shared timeline');
+ const scales = await graph.evaluate(svg => [...svg.querySelectorAll('circle')].map((dot, index) => {
+ const bar = svg.querySelectorAll('rect')[index], bottom = Number(bar.getAttribute('y')) + Number(bar.getAttribute('height'));
+ const submitted = [20, 5, 12][index], accepted = [8, 2, 4][index];
+ return Math.abs((bottom - Number(dot.getAttribute('cy'))) / submitted - Number(bar.getAttribute('height')) / accepted);
+ }));
+ assert.ok(scales.every(offset => offset < 0.00001), 'both series must share the same count scale and baseline');
+ assert.deepEqual(await graph.locator('circle title').allTextContents(), ['2024-01-01 提交:20', '2024-01-02 提交:5', '2024-01-03 提交:12']);
+ assert.deepEqual(await graph.locator('rect title').allTextContents(), ['2024-01-01 正确:8', '2024-01-02 正确:2', '2024-01-03 正确:4']);
+ assert.ok((await graph.locator('path').getAttribute('d')).startsWith('M'));
+ await page.addStyleTag({content: '#UserScriptProfile {display:flex} #UserScriptProfile > div {width:50%;min-width:0}'});
+ await page.setViewportSize({width: 640, height: 800});
+ await page.waitForFunction(() => Number(document.querySelector('#submission svg').getAttribute('viewBox').split(' ')[2]) < 400);
+ const boxes = await graph.locator('text').evaluateAll(nodes => nodes.map(node => ({x: node.getBBox().x, right: node.getBBox().x + node.getBBox().width})));
+ assert.ok(boxes.every(box => box.x >= 0 && box.right <= 320), 'axis labels must fit the narrow viewBox');
+ assert.ok(await graph.locator('rect').evaluateAll(nodes => nodes.every(node => Number(node.getAttribute('height')) > 0)));
+ await page.close();
+ });
+ }
+ await t.test('a single history point is visible with one centered date label', async () => {
+ const html = fixture.replace(/1020 bad ', 'text/html');
+ return GetProfileSolvedProblems(doc);
+ });
+ assert.deepEqual(ids, [1020, 1000, 1010]);
+ assert.equal(await page.evaluate(() => shouldNeverRun), false);
+ await page.close();
+ });
+ await t.test('leaves missing-user and error pages intact', async () => {
+ const page = await Page({html: 'No such User! '});
+ assert.equal(await page.locator('h1').innerText(), 'No such User!');
+ assert.equal(await page.locator('#UserScriptProfile').count(), 0);
+ assert.deepEqual(await page.evaluate(() => apiCalls), []);
+ await page.close();
+ });
+ } finally { await browser.close(); }
+});
+
+test('profile startup precedes the general page handler and standard uploads reuse solved parsing', () => {
+ const startup = source.indexOf('InitializeUserProfile(IsAdmin);');
+ const main = source.indexOf('await main();');
+ assert.ok(startup >= 0 && main >= 0 && startup < main);
+ assert.match(source, /ACList = GetProfileSolvedProblems\(ParsedDocument\);/);
+});