Jump to content

User:ChasingAir/js/QuickProtect.js

From testwiki, a Wikimedia project for testing

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * QuickProtect - 快速保护小工具 (Codex 版本)
 * 劫持"更多"菜单中的"保护"/"更改保护"链接,弹出Codex Dialog窗口进行快速保护操作
 */
// <nowiki>
(function () {
	'use strict';

	// 只在有保护权限时运行
	if (mw.config.get('wgUserGroups').indexOf('sysop') === -1) {
		return;
	}

	var pageName = mw.config.get('wgPageName');
	var pageTitle = mw.config.get('wgTitle');
	var pageExists = mw.config.get('wgArticleId') > 0;
	var api = new mw.Api();

	// 保护原因选项
	var protectReasons = [
		{ value: '', label: '选择保护原因', disabled: true },
		{ value: '其他', label: '其他' },
		{ value: '过度破坏', label: '过度破坏' },
		{ value: '过多垃圾信息', label: '过多垃圾信息' },
		{ value: '编辑战', label: '编辑战' },
		{ value: '高流量页面', label: '高流量页面' },
		{ value: '傀儡破坏', label: '傀儡破坏' },
		{ value: '重要政策页面', label: '重要政策页面' },
		{ value: '永久封禁用户的用户页', label: '永久封禁用户的用户页' },
		{ value: '嵌入至MediaWiki空间的页面', label: '嵌入至MediaWiki空间的页面' }
	];

	// 保护级别选项
	var protectLevels = [
		{ value: '', label: '无限制' },
		{ value: 'autoconfirmed', label: '仅允许自动确认用户' },
		{ value: 'sysop', label: '仅允许管理员' }
	];

	// 保护期限选项
	var expiryOptions = [
		{ value: 'infinite', label: '无限期' },
		{ value: '1 hour', label: '1小时' },
		{ value: '2 hours', label: '2小时' },
		{ value: '1 day', label: '1天' },
		{ value: '3 days', label: '3天' },
		{ value: '1 week', label: '1周' },
		{ value: '2 weeks', label: '2周' },
		{ value: '1 month', label: '1个月' },
		{ value: '3 months', label: '3个月' },
		{ value: '6 months', label: '6个月' },
		{ value: '1 year', label: '1年' },
		{ value: 'custom', label: '自定义' }
	];

	// 获取保护信息
	function getProtectionInfo() {
		var promises = [];

		// 获取当前保护状态
		promises.push(api.get({
			action: 'query',
			titles: pageName,
			prop: 'info',
			inprop: 'protection',
			format: 'json'
		}).then(function (data) {
			var pages = data.query.pages;
			var pageId = Object.keys(pages)[0];
			var page = pages[pageId];
			return {
				protections: page.protection || []
			};
		}));

		// 获取保护历史
		promises.push(api.get({
			action: 'query',
			list: 'logevents',
			letype: 'protect',
			letitle: pageName,
			lelimit: 10,
			format: 'json'
		}).then(function (data) {
			return {
				history: data.query && data.query.logevents ? data.query.logevents : []
			};
		}));

		return $.when.apply($, promises).then(function () {
			var result = {};
			for (var i = 0; i < arguments.length; i++) {
				$.extend(result, arguments[i]);
			}
			return result;
		});
	}

	// 格式化保护级别
	function formatLevel(level) {
		if (level === 'autoconfirmed') return '自动确认用户';
		if (level === 'sysop') return '管理员';
		if (level === 'extendedconfirmed') return '延伸确认用户';
		if (level === '') return '无限制';
		return level;
	}

	// 格式化保护类型
	function formatType(type) {
		if (type === 'edit') return '编辑';
		if (type === 'move') return '移动';
		if (type === 'create') return '创建';
		if (type === 'upload') return '上传';
		return type;
	}

	// 格式化期限
	function formatExpiry(expiry) {
		if (expiry === 'infinity' || expiry === 'infinite') return '无限期';
		return new Date(expiry).toLocaleString('zh-CN');
	}

	// 格式化日志操作
	function formatAction(action) {
		if (action === 'protect') return '保护';
		if (action === 'modify') return '修改保护';
		if (action === 'unprotect') return '解除保护';
		if (action === 'move_prot') return '移动保护';
		return action;
	}

	// 显示保护对话框
	function showProtectDialog() {
		mw.loader.using(['@wikimedia/codex', 'mediawiki.api']).then(function (require) {
			var Vue = require('vue');
			var Codex = require('@wikimedia/codex');

			// 创建挂载点
			var mountPoint = document.body.appendChild(document.createElement('div'));
			mountPoint.id = 'quickprotect-app';

			// 添加自定义样式
			if (!document.getElementById('quickprotect-styles')) {
				var style = document.createElement('style');
				style.id = 'quickprotect-styles';
				style.textContent = [
					'.quickprotect-dialog { width: 850px !important; max-width: 95vw; }',
					'.quickprotect-dialog .cdx-dialog__body { padding: 16px; max-height: 70vh; overflow-y: auto; }',
					'.quickprotect-loading { text-align: center; padding: 20px; }',
					'.quickprotect-loading p { margin-top: 12px; color: #54595d; }',
					'.quickprotect-progress { text-align: center; padding: 20px; }',
					'.quickprotect-progress p { margin-top: 12px; }',
					'.quickprotect-progress-status { font-weight: bold; font-size: 14px; }',
					'.quickprotect-progress-success { color: #14866d; }',
					'.quickprotect-progress-error { color: #d73333; }',
					'.quickprotect-layout { display: flex; gap: 16px; }',
					'.quickprotect-left { flex: 1; min-width: 350px; }',
					'.quickprotect-right { width: 450px; flex-shrink: 0; display: flex; flex-direction: column; max-height: 400px; }',
					'.quickprotect-section { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #eaecf0; }',
					'.quickprotect-section:last-child { border-bottom: none; margin-bottom: 0; }',
					'.quickprotect-section-title { font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #202122; }',
					'.quickprotect-field { margin-bottom: 8px; }',
					'.quickprotect-field:last-child { margin-bottom: 0; }',
					'.quickprotect-status { background: #f8f9fa; border: 1px solid #eaecf0; border-radius: 4px; padding: 12px; margin-bottom: 12px; flex-shrink: 0; }',
					'.quickprotect-status-header { display: flex; justify-content: space-between; align-items: center; }',
					'.quickprotect-status-text { font-weight: bold; font-size: 13px; }',
					'.quickprotect-status-protected { color: #d73333; }',
					'.quickprotect-status-unprotected { color: #14866d; }',
					'.quickprotect-status-details { margin-top: 8px; font-size: 12px; color: #54595d; }',
					'.quickprotect-history { background: #f8f9fa; border: 1px solid #eaecf0; border-radius: 4px; padding: 12px; overflow-y: auto; }',
					'.quickprotect-history-title { font-weight: bold; font-size: 13px; margin-bottom: 10px; color: #202122; }',
					'.quickprotect-history-item { padding: 8px 0; border-bottom: 1px solid #eaecf0; font-size: 12px; }',
					'.quickprotect-history-item:last-child { border-bottom: none; padding-bottom: 0; }',
					'.quickprotect-history-date { color: #72777d; margin-bottom: 2px; }',
					'.quickprotect-history-action { font-weight: bold; color: #3366cc; }',
					'.quickprotect-history-details { color: #54595d; margin-top: 4px; }',
					'.quickprotect-history-comment { color: #202122; margin-top: 4px; font-style: italic; }'
				].join('\n');
				document.head.appendChild(style);
			}

			Vue.createMwApp({
				data: function () {
					return {
						showDialog: true,
						isLoading: true,
						isSubmitting: false,
						submitComplete: false,
						submitSuccess: false,
						submitError: '',
						protectionInfo: null,
						// Form data
						editLevel: '',
						editExpiry: 'infinite',
						editCustomExpiry: '',
						moveLevel: '',
						moveExpiry: 'infinite',
						moveCustomExpiry: '',
						createLevel: '',
						createExpiry: 'infinite',
						createCustomExpiry: '',
						cascade: false,
						selectedReason: '',
						detailReason: '',
						// Options
						protectLevelOptions: protectLevels,
						expiryOptions: expiryOptions,
						reasonOptions: protectReasons
					};
				},
				computed: {
					dialogTitle: function () {
						return '保护页面: ' + pageTitle;
					},
					primaryAction: function () {
						if (this.submitComplete) {
							return {
								label: '关闭',
								actionType: 'default'
							};
						}
						return {
							label: '提交',
							actionType: 'progressive',
							disabled: this.isLoading || this.isSubmitting
						};
					},
					defaultAction: function () {
						if (this.isSubmitting || this.submitComplete) {
							return null;
						}
						return {
							label: '取消'
						};
					},
					hasProtection: function () {
						return this.protectionInfo &&
							this.protectionInfo.protections &&
							this.protectionInfo.protections.length > 0;
					},
					protectionDetails: function () {
						if (!this.hasProtection) return [];
						var details = [];
						this.protectionInfo.protections.forEach(function (prot) {
							details.push({
								type: formatType(prot.type),
								level: formatLevel(prot.level),
								expiry: formatExpiry(prot.expiry)
							});
						});
						return details;
					},
					historyItems: function () {
						if (!this.protectionInfo || !this.protectionInfo.history) return [];
						var items = [];
						this.protectionInfo.history.forEach(function (log) {
							var item = {
								date: new Date(log.timestamp).toLocaleString('zh-CN', {
									year: 'numeric',
									month: '2-digit',
									day: '2-digit',
									hour: '2-digit',
									minute: '2-digit'
								}),
								action: formatAction(log.action),
								user: log.user,
								userUrl: mw.util.getUrl('User:' + log.user),
								comment: log.comment || '',
								details: ''
							};

							// Parse protection details
							if (log.params && log.params.details) {
								var detailParts = [];
								log.params.details.forEach(function (detail) {
									var expiry = '';
									if (detail.expiry) {
										expiry = detail.expiry === 'infinity' || detail.expiry === 'infinite'
											? '无限期'
											: new Date(detail.expiry).toLocaleString('zh-CN', {
												year: 'numeric',
												month: '2-digit',
												day: '2-digit',
												hour: '2-digit',
												minute: '2-digit'
											});
									}
									detailParts.push(
										formatType(detail.type) + '-' +
										formatLevel(detail.level) +
										(expiry ? ' (' + expiry + ')' : '')
									);
								});
								if (detailParts.length > 0) {
									item.details = '[' + detailParts.join(' | ') + ']';
								}
							} else if (log.params && (log.params.description || log.params['4::description'])) {
								item.details = '[' + (log.params.description || log.params['4::description']) + ']';
							}

							items.push(item);
						});
						return items;
					},
					showEditCustomExpiry: function () {
						return this.editExpiry === 'custom';
					},
					showMoveCustomExpiry: function () {
						return this.moveExpiry === 'custom';
					},
					showCreateCustomExpiry: function () {
						return this.createExpiry === 'custom';
					}
				},
				methods: {
					loadProtectionInfo: function () {
						var self = this;
						getProtectionInfo().then(function (info) {
							self.protectionInfo = info;
							self.isLoading = false;
						}).catch(function (error) {
							mw.notify('加载保护信息失败:' + error, { type: 'error' });
							self.closeDialog();
						});
					},
					fillCurrentProtection: function () {
						var self = this;
						if (!this.protectionInfo || !this.protectionInfo.protections) return;

						this.protectionInfo.protections.forEach(function (prot) {
							var level = prot.level || '';
							var expiry = prot.expiry === 'infinity' ? 'infinite' : prot.expiry;

							// Check if expiry matches a preset
							var foundExpiry = 'custom';
							expiryOptions.forEach(function (option) {
								if (option.value === expiry ||
									(option.value === 'infinite' && expiry === 'infinity')) {
									foundExpiry = option.value;
								}
							});

							if (prot.type === 'edit') {
								self.editLevel = level;
								self.editExpiry = foundExpiry;
								if (foundExpiry === 'custom' && expiry !== 'infinity') {
									self.editCustomExpiry = expiry;
								}
							} else if (prot.type === 'move') {
								self.moveLevel = level;
								self.moveExpiry = foundExpiry;
								if (foundExpiry === 'custom' && expiry !== 'infinity') {
									self.moveCustomExpiry = expiry;
								}
							} else if (prot.type === 'create') {
								self.createLevel = level;
								self.createExpiry = foundExpiry;
								if (foundExpiry === 'custom' && expiry !== 'infinity') {
									self.createCustomExpiry = expiry;
								}
							}
						});

						mw.notify('已将当前保护设置填入表单', { type: 'info' });
					},
					getExpiry: function (selected, custom) {
						if (selected === 'custom') {
							return custom || 'infinite';
						}
						return selected;
					},
					buildReason: function () {
						var selected = this.selectedReason;
						var detail = this.detailReason.trim();

						if (selected === '其他' || selected === '') {
							return detail;
						} else if (detail) {
							return selected + ':' + detail;
						} else {
							return selected;
						}
					},
					handlePrimary: function () {
						var self = this;

						if (this.submitComplete) {
							this.closeDialog();
							location.reload();
							return;
						}

						var reason = this.buildReason();
						if (!reason) {
							mw.notify('请选择或输入保护原因', { type: 'error' });
							return;
						}

						this.isSubmitting = true;

						var protections = [];
						var expiry = [];

						if (pageExists) {
							protections.push('edit=' + this.editLevel);
							protections.push('move=' + this.moveLevel);
							expiry.push(this.getExpiry(this.editExpiry, this.editCustomExpiry));
							expiry.push(this.getExpiry(this.moveExpiry, this.moveCustomExpiry));
						} else {
							protections.push('create=' + this.createLevel);
							expiry.push(this.getExpiry(this.createExpiry, this.createCustomExpiry));
						}

						var params = {
							action: 'protect',
							title: pageName,
							protections: protections.join('|'),
							expiry: expiry.join('|'),
							reason: reason,
							token: mw.user.tokens.get('csrfToken')
						};

						if (this.cascade && pageExists) {
							params.cascade = true;
						}

						api.postWithToken('csrf', params).then(function () {
							self.submitComplete = true;
							self.submitSuccess = true;
						}).catch(function (code, data) {
							self.submitComplete = true;
							self.submitSuccess = false;
							var errorMsg = '保护失败';
							if (data && data.error && data.error.info) {
								errorMsg = data.error.info;
							} else if (code) {
								errorMsg = code;
							}
							self.submitError = errorMsg;
						});
					},
					handleDefault: function () {
						this.closeDialog();
					},
					closeDialog: function () {
						this.showDialog = false;
						setTimeout(function () {
							var el = document.getElementById('quickprotect-app');
							if (el) {
								el.remove();
							}
						}, 300);
					}
				},
				mounted: function () {
					this.loadProtectionInfo();
				},
				template: `
                    <cdx-dialog
                        v-model:open="showDialog"
                        :title="dialogTitle"
                        class="quickprotect-dialog"
                        :primary-action="primaryAction"
                        :default-action="defaultAction"
                        @primary="handlePrimary"
                        @default="handleDefault"
                        @update:open="!$event && closeDialog()"
                    >
                        <!-- 加载中状态 -->
                        <div v-if="isLoading" class="quickprotect-loading">
                            <cdx-progress-bar aria-label="正在加载" />
                            <p>正在加载保护信息...</p>
                        </div>

                        <!-- 提交中/完成状态 -->
                        <div v-else-if="isSubmitting || submitComplete" class="quickprotect-progress">
                            <cdx-progress-bar v-if="isSubmitting && !submitComplete" aria-label="正在提交" />
                            <p v-if="isSubmitting && !submitComplete">正在提交保护请求...</p>
                            <p v-if="submitComplete && submitSuccess" class="quickprotect-progress-status quickprotect-progress-success">
                                ✓ 保护操作成功
                            </p>
                            <p v-if="submitComplete && !submitSuccess" class="quickprotect-progress-status quickprotect-progress-error">
                                ✗ {{ submitError }}
                            </p>
                        </div>

                        <!-- 主表单 -->
                        <div v-else class="quickprotect-layout">
                            <!-- 左栏:表单控件 -->
                            <div class="quickprotect-left">
                                <!-- 页面存在时:编辑保护 -->
                                <template v-if="${pageExists}">
                                    <div class="quickprotect-section">
                                        <div class="quickprotect-section-title">编辑保护</div>
                                        <cdx-field class="quickprotect-field">
                                            <template #label>级别</template>
                                            <cdx-select
                                                v-model:selected="editLevel"
                                                :menu-items="protectLevelOptions"
                                            />
                                        </cdx-field>
                                        <cdx-field class="quickprotect-field">
                                            <template #label>期限</template>
                                            <cdx-select
                                                v-model:selected="editExpiry"
                                                :menu-items="expiryOptions"
                                            />
                                        </cdx-field>
                                        <cdx-field v-if="showEditCustomExpiry" class="quickprotect-field">
                                            <template #label>自定义期限</template>
                                            <cdx-text-input
                                                v-model="editCustomExpiry"
                                                placeholder="例如: 2025-12-31T23:59:59Z"
                                            />
                                        </cdx-field>
                                    </div>

                                    <div class="quickprotect-section">
                                        <div class="quickprotect-section-title">移动保护</div>
                                        <cdx-field class="quickprotect-field">
                                            <template #label>级别</template>
                                            <cdx-select
                                                v-model:selected="moveLevel"
                                                :menu-items="protectLevelOptions"
                                            />
                                        </cdx-field>
                                        <cdx-field class="quickprotect-field">
                                            <template #label>期限</template>
                                            <cdx-select
                                                v-model:selected="moveExpiry"
                                                :menu-items="expiryOptions"
                                            />
                                        </cdx-field>
                                        <cdx-field v-if="showMoveCustomExpiry" class="quickprotect-field">
                                            <template #label>自定义期限</template>
                                            <cdx-text-input
                                                v-model="moveCustomExpiry"
                                                placeholder="例如: 2025-12-31T23:59:59Z"
                                            />
                                        </cdx-field>
                                    </div>

                                    <div class="quickprotect-section">
                                        <cdx-checkbox v-model="cascade">
                                            保护嵌入该页面的页面(连锁保护)
                                        </cdx-checkbox>
                                    </div>
                                </template>

                                <!-- 页面不存在时:创建保护 -->
                                <template v-else>
                                    <div class="quickprotect-section">
                                        <div class="quickprotect-section-title">创建保护</div>
                                        <cdx-field class="quickprotect-field">
                                            <template #label>级别</template>
                                            <cdx-select
                                                v-model:selected="createLevel"
                                                :menu-items="protectLevelOptions"
                                            />
                                        </cdx-field>
                                        <cdx-field class="quickprotect-field">
                                            <template #label>期限</template>
                                            <cdx-select
                                                v-model:selected="createExpiry"
                                                :menu-items="expiryOptions"
                                            />
                                        </cdx-field>
                                        <cdx-field v-if="showCreateCustomExpiry" class="quickprotect-field">
                                            <template #label>自定义期限</template>
                                            <cdx-text-input
                                                v-model="createCustomExpiry"
                                                placeholder="例如: 2025-12-31T23:59:59Z"
                                            />
                                        </cdx-field>
                                    </div>
                                </template>

                                <!-- 保护原因 -->
                                <div class="quickprotect-section">
                                    <div class="quickprotect-section-title">保护原因</div>
                                    <cdx-field class="quickprotect-field">
                                        <template #label>原因</template>
                                        <cdx-select
                                            v-model:selected="selectedReason"
                                            :menu-items="reasonOptions"
                                            default-label="选择保护原因"
                                        />
                                    </cdx-field>
                                    <cdx-field class="quickprotect-field">
                                        <template #label>详细说明</template>
                                        <cdx-text-input
                                            v-model="detailReason"
                                            placeholder="输入详细原因"
                                        />
                                    </cdx-field>
                                </div>
                            </div>

                            <!-- 右栏:保护状态和历史 -->
                            <div class="quickprotect-right">
                                <div class="quickprotect-status">
                                    <div class="quickprotect-status-header">
                                        <span 
                                            class="quickprotect-status-text"
                                            :class="hasProtection ? 'quickprotect-status-protected' : 'quickprotect-status-unprotected'"
                                        >
                                            {{ hasProtection ? '当前有生效保护' : '无生效保护' }}
                                        </span>
                                        <cdx-button
                                            v-if="hasProtection"
                                            action="progressive"
                                            weight="quiet"
                                            @click="fillCurrentProtection"
                                        >
                                            修改
                                        </cdx-button>
                                    </div>
                                    <div v-if="hasProtection" class="quickprotect-status-details">
                                        <div v-for="(detail, idx) in protectionDetails" :key="idx">
                                            {{ detail.type }}: {{ detail.level }} ({{ detail.expiry }})
                                        </div>
                                    </div>
                                </div>

                                <div class="quickprotect-history">
                                    <div class="quickprotect-history-title">保护历史</div>
                                    <div v-if="historyItems.length === 0">无保护历史</div>
                                    <div 
                                        v-for="(item, idx) in historyItems" 
                                        :key="idx"
                                        class="quickprotect-history-item"
                                    >
                                        <div class="quickprotect-history-date">{{ item.date }}</div>
                                        <div>
                                            <span class="quickprotect-history-action">{{ item.action }}</span>
                                            由 <a :href="item.userUrl" target="_blank">{{ item.user }}</a>
                                        </div>
                                        <div v-if="item.details" class="quickprotect-history-details">
                                            {{ item.details }}
                                        </div>
                                        <div v-if="item.comment" class="quickprotect-history-comment">
                                            <strong>理由:</strong>{{ item.comment }}
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </cdx-dialog>
                `
			})
				.component('cdx-dialog', Codex.CdxDialog)
				.component('cdx-button', Codex.CdxButton)
				.component('cdx-select', Codex.CdxSelect)
				.component('cdx-text-input', Codex.CdxTextInput)
				.component('cdx-checkbox', Codex.CdxCheckbox)
				.component('cdx-field', Codex.CdxField)
				.component('cdx-progress-bar', Codex.CdxProgressBar)
				.mount(mountPoint);
		});
	}

	// 劫持保护链接
	function hijackProtectLink() {
		// 保护链接可能是 #ca-protect (未保护页面) 或 #ca-unprotect (已保护页面)
		var $protectLink = $('#ca-protect a, #ca-unprotect a');

		if ($protectLink.length === 0) {
			return;
		}

		$protectLink.off('click').on('click', function (e) {
			e.preventDefault();
			e.stopPropagation();
			showProtectDialog();
			return false;
		});

		// 移除原有的href,防止中键点击
		$protectLink.attr('href', '#');
	}

	// 页面加载后初始化
	$(function () {
		mw.loader.using('mediawiki.util').then(function () {
			hijackProtectLink();
		});
	});

	// 如果使用Vector 2022皮肤,需要等待菜单加载
	mw.hook('wikipage.content').add(function () {
		hijackProtectLink();
	});

})();
// </nowiki>