User:ChasingAir/js/QuickProtect.js
Appearance
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>