来自3D坦克-WIKI
注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。
- Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
- Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
- Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/*
* MediaWiki 批量上传页面 v1.0
* 将本文件全文保存到 MediaWiki:BatchUpload.js(不要加 <script> 标签)。
* 入口:Project:批量上传;加载片段见 Common.js.snippet.js。
* 依赖:MediaWiki 自带 mediawiki.api / mediawiki.util / jQuery;现代浏览器。
* 不使用外部服务、不保存密码、不请求忽略上传警告。
*/
(function () {
'use strict';
var CONFIG = {
namespace: 4,
pageTitle: '批量上传',
containerId: 'mw-batch-upload',
maxFiles: 250, // 本地队列上限,不是服务器限制
intervalMs: 500 // 两个文件之间的间隔;上传始终串行
};
if (mw.config.get('wgNamespaceNumber') !== CONFIG.namespace ||
mw.config.get('wgTitle') !== CONFIG.pageTitle ||
mw.config.get('wgAction') !== 'view' ||
mw.config.get('wgDiffNewId')) {
return;
}
mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
jQuery(function () {
var root = document.getElementById(CONFIG.containerId);
if (!root || root.dataset.batchUploadReady) {
return;
}
root.dataset.batchUploadReady = '1';
mount(root);
});
}, function () {
jQuery(function () {
var root = document.getElementById(CONFIG.containerId);
if (root) {
root.textContent = '无法加载 MediaWiki API 模块。请强制刷新,或联系界面管理员。';
}
});
});
function mount(root) {
mw.util.addCSS([
'#mw-batch-upload{--bu-border:var(--border-color-subtle,#c8ccd1);color:var(--color-base,#202122);font-size:14px;line-height:1.6;margin:1em 0}',
'#mw-batch-upload *{box-sizing:border-box}',
'#mw-batch-upload .bu-panel{border:1px solid var(--bu-border);border-radius:8px;padding:20px;background:var(--background-color-base,#fff)}',
'#mw-batch-upload .bu-heading{font-size:21px;font-weight:700;margin:0 0 6px}',
'#mw-batch-upload .bu-note{color:var(--color-subtle,#54595d);margin:6px 0 12px;font-size:13px}',
'#mw-batch-upload .bu-drop{padding:24px 16px;text-align:center;border:2px dashed var(--bu-border);border-radius:6px;margin:16px 0;background:var(--background-color-interactive-subtle,#f8f9fa)}',
'#mw-batch-upload .bu-drop.bu-over{border-color:#36c;background:#eaf3ff;color:#202122}',
'#mw-batch-upload .bu-fields{display:grid;grid-template-columns:2fr 1fr;gap:16px;margin-bottom:14px}',
'#mw-batch-upload label{display:block;font-weight:600}',
'#mw-batch-upload input[type=text],#mw-batch-upload textarea{width:100%;padding:8px;border:1px solid var(--bu-border);border-radius:4px;color:inherit;background:var(--background-color-base,#fff);font:inherit;font-weight:400}',
'#mw-batch-upload textarea{min-height:100px;resize:vertical}',
'#mw-batch-upload .bu-inline{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0 16px}',
'#mw-batch-upload .bu-inline input{flex:1;min-width:140px}',
'#mw-batch-upload button{padding:7px 13px;border:1px solid var(--bu-border);border-radius:4px;font:inherit;font-weight:600;cursor:pointer;background:var(--background-color-interactive,#eaecf0);color:inherit}',
'#mw-batch-upload button.bu-primary{background:#36c;border-color:#36c;color:#fff}',
'#mw-batch-upload button:disabled{opacity:.5;cursor:not-allowed}',
'#mw-batch-upload button:focus-visible,#mw-batch-upload input:focus-visible,#mw-batch-upload textarea:focus-visible{outline:2px solid #36c;outline-offset:2px}',
'#mw-batch-upload .bu-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}',
'#mw-batch-upload .bu-message{padding:10px 12px;margin:10px 0;border-left:3px solid #36c;background:var(--background-color-interactive-subtle,#f8f9fa);white-space:pre-wrap;overflow-wrap:anywhere}',
'#mw-batch-upload .bu-scroll{overflow-x:auto}',
'#mw-batch-upload table{width:100%;min-width:630px;border-collapse:collapse;margin-top:12px}',
'#mw-batch-upload th,#mw-batch-upload td{padding:10px 8px;border-bottom:1px solid var(--bu-border);text-align:left;vertical-align:top}',
'#mw-batch-upload th{font-weight:600;background:var(--background-color-interactive-subtle,#f8f9fa)}',
'#mw-batch-upload .bu-original{width:23%;overflow-wrap:anywhere}',
'#mw-batch-upload .bu-target{width:31%}',
'#mw-batch-upload .bu-result{width:36%;overflow-wrap:anywhere}',
'#mw-batch-upload .bu-size{font-size:12px;color:var(--color-subtle,#54595d)}',
'#mw-batch-upload .bu-status{white-space:pre-wrap}',
'#mw-batch-upload tr[data-state=success] .bu-status{color:var(--color-success,#14866d)}',
'#mw-batch-upload tr[data-state=warning] .bu-status{color:var(--color-warning,#8b5a00)}',
'#mw-batch-upload tr[data-state=failed] .bu-status{color:var(--color-error,#b32424)}',
'#mw-batch-upload progress{display:block;width:100%;height:8px;accent-color:#36c;margin:7px 0}',
'#mw-batch-upload .bu-empty{text-align:center;color:var(--color-subtle,#54595d);padding:24px}',
'#mw-batch-upload [hidden]{display:none!important}',
'@media(max-width:650px){#mw-batch-upload .bu-fields{grid-template-columns:1fr}#mw-batch-upload .bu-panel{padding:14px}}'
].join('\n'));
// HTML 全部为静态字符串。文件名、API 错误、用户输入只以 textContent/value 写入。
root.innerHTML = `
<section class="bu-panel" aria-label="批量上传文件">
<div class="bu-heading">批量上传文件</div>
<p class="bu-note">上传到当前 Wiki,使用当前账号的权限。遇到警告不自动确认,不主动覆盖已有文件。</p>
<div class="bu-message" data-el="message" role="status" aria-live="polite">正在检查登录状态和上传权限……</div>
<div class="bu-drop" data-el="drop">
<button type="button" data-el="choose" disabled>选择多个文件</button>
<span> 或将文件拖到这里</span>
<input type="file" multiple hidden data-el="files" aria-label="选择待上传文件" disabled>
<div class="bu-note">可以分多次添加。类型和大小由本站设置决定;本工具不支持目录或分片上传。</div>
</div>
<div class="bu-fields">
<label>统一文件说明(维基语法)
<textarea data-el="description" placeholder="填写来源、作者和授权说明。若使用许可模板,请填写本站已有、且确实适用的模板。" disabled></textarea>
</label>
<label>分类(可选,每行一个名称)
<textarea data-el="categories" placeholder="活动照片 2026年资料" disabled></textarea>
</label>
</div>
<label for="bu-prefix">文件名前缀(可选)</label>
<div class="bu-inline">
<input id="bu-prefix" data-el="prefix" type="text" placeholder="例如:项目A-" disabled>
<button type="button" data-el="applyPrefix" disabled>应用到未成功项</button>
</div>
<p class="bu-note">应用前缀会按“前缀 + 原始文件名”重建未成功项的名称,替换手工改名;不会重复叠加前缀。请保留正确的扩展名。</p>
<div class="bu-toolbar">
<button type="button" class="bu-primary" data-el="start" disabled>开始上传</button>
<button type="button" data-el="pause" disabled>本项完成后暂停</button>
<button type="button" data-el="retry" disabled>重试失败 / 警告项</button>
<button type="button" data-el="clear" disabled>清空列表</button>
</div>
<div data-el="summary" role="status" aria-live="polite">尚未选择文件</div>
<progress data-el="overall" max="1" value="0" aria-label="处理进度(按文件数量)"></progress>
<div class="bu-scroll">
<table>
<thead><tr><th scope="col">本地文件</th><th scope="col">目标文件名</th><th scope="col">状态 / 传输进度</th><th scope="col">操作</th></tr></thead>
<tbody data-el="rows"></tbody>
</table>
<div class="bu-empty" data-el="empty">选择文件后,会在这里显示上传队列。</div>
</div>
<p class="bu-note">进度条表示传输或已处理数量,不代表上传成功;以“成功”状态为准。暂停不会中断正在处理的文件。清空列表不会删除站内文件。</p>
</section>`;
var ui = {};
root.querySelectorAll('[data-el]').forEach(function (el) { ui[el.dataset.el] = el; });
var items = [];
var busy = false;
var pauseRequested = false;
var ready = false;
var userName = mw.config.get('wgUserName');
var api;
var fileNamespace = (mw.config.get('wgFormattedNamespaces') || {})[6] || 'File';
var categoryNamespace = (mw.config.get('wgFormattedNamespaces') || {})[14] || 'Category';
function message(text) { ui.message.textContent = text; }
function fileUrl(name) { return mw.util.getUrl(fileNamespace + ':' + name); }
function sleep(ms) { return new Promise(function (resolve) { window.setTimeout(resolve, ms); }); }
function sizeText(bytes) {
if (bytes < 1024) { return bytes + ' B'; }
if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KiB'; }
return (bytes / 1048576).toFixed(1) + ' MiB';
}
function counts() {
var result = { queued: 0, uploading: 0, success: 0, warning: 0, failed: 0 };
items.forEach(function (item) { result[item.state]++; });
return result;
}
function refresh() {
var c = counts();
var locked = busy || !ready;
['choose', 'files', 'description', 'categories', 'prefix'].forEach(function (key) {
ui[key].disabled = locked;
});
ui.applyPrefix.disabled = locked || !items.some(function (item) { return item.state !== 'success'; });
ui.start.disabled = locked || !c.queued;
ui.pause.disabled = !busy || pauseRequested;
ui.retry.disabled = locked || !(c.failed + c.warning);
ui.clear.disabled = busy || !items.length;
ui.empty.hidden = items.length > 0;
ui.overall.max = Math.max(items.length, 1);
ui.overall.value = c.success + c.warning + c.failed;
ui.summary.textContent = items.length ?
'共 ' + items.length + ' 项 · 成功 ' + c.success + ' · 警告 ' + c.warning +
' · 失败 ' + c.failed + ' · 待处理 ' + c.queued + (busy ? ' · 正在处理' : '') : '尚未选择文件';
items.forEach(function (item) {
item.input.disabled = locked || item.state === 'success';
item.remove.disabled = busy;
});
}
function setState(item, state, text) {
item.state = state;
item.row.dataset.state = state;
item.status.textContent = text;
item.progress.hidden = state !== 'uploading' && state !== 'success';
if (state === 'queued') { item.progress.value = 0; }
if (state === 'success') { item.progress.value = 100; }
item.link.hidden = state !== 'success' && state !== 'warning' && state !== 'failed';
item.link.href = fileUrl(item.input.value.trim());
item.link.textContent = state === 'success' ? '查看文件' : '检查目标页面';
refresh();
}
function resetItem(item) {
if (item.state !== 'success') { setState(item, 'queued', '等待上传'); }
}
function addFiles(files) {
if (busy || !ready) { return; }
var added = 0;
var excess = 0;
Array.from(files).forEach(function (file) {
if (items.length >= CONFIG.maxFiles) { excess++; return; }
var tr = document.createElement('tr');
tr.dataset.state = 'queued';
tr.innerHTML = '<td class="bu-original"><div class="bu-name"></div><div class="bu-size"></div></td>' +
'<td class="bu-target"><input type="text"></td>' +
'<td class="bu-result"><div class="bu-status"></div><progress max="100" value="0" hidden></progress><a target="_blank" rel="noopener noreferrer" hidden></a></td>' +
'<td><button type="button" title="只移出队列,不删除站内文件">移除</button></td>';
tr.querySelector('.bu-name').textContent = file.name;
tr.querySelector('.bu-size').textContent = sizeText(file.size);
var item = {
file: file, state: 'queued', row: tr,
input: tr.querySelector('input'), status: tr.querySelector('.bu-status'),
progress: tr.querySelector('progress'), link: tr.querySelector('a'),
remove: tr.querySelector('button')
};
item.input.value = ui.prefix.value + file.name;
item.input.setAttribute('aria-label', file.name + ' 的目标文件名');
item.progress.setAttribute('aria-label', file.name + ' 的传输进度');
item.status.textContent = '等待上传';
item.input.addEventListener('input', function () { resetItem(item); });
item.remove.addEventListener('click', function () {
if (busy) { return; }
items = items.filter(function (other) { return other !== item; });
tr.remove();
refresh();
});
items.push(item);
ui.rows.appendChild(tr);
added++;
});
message('已添加 ' + added + ' 个文件。请检查文件名、来源和授权说明后点击“开始上传”。' +
(excess ? '\n另有 ' + excess + ' 个文件未添加:本地队列上限为 ' + CONFIG.maxFiles + ' 个。' : ''));
refresh();
}
// 将 mw.Api 的多参数拒绝转换为 Error;不丢失服务器的错误信息。
function apiError(code, data) {
var raw = data && (data.error || (data.errors && data.errors[0]));
var text = raw && (raw.info || raw.text || raw['*']);
var status = data && data.xhr && data.xhr.status;
if (code === 'http') {
text = '网络 / HTTP 异常' + (status ? '(HTTP ' + status + ')' : '') +
'。若上传请求已发出,结果可能未确认;请先检查目标页面,再决定是否重试。';
}
var error = new Error(text || String(code || '未知 API 错误'));
error.code = String(code || 'unknown');
error.stopQueue = code === 'http' || /assert|badtoken|notloggedin|read.?only|upload.*disabled|ratelimited|maxlag|permissiondenied|blocked/i.test(error.code);
return error;
}
function request(jqPromise) {
return new Promise(function (resolve, reject) {
jqPromise.done(resolve).fail(function (code, data) { reject(apiError(code, data)); });
});
}
function warningError(text) {
var error = new Error(text);
error.isWarning = true;
return error;
}
function warningText(warnings) {
var labels = {
exists: '同名文件已存在',
'exists-normalized': '规范化后的文件名已存在',
'page-exists': '同名文件说明页已存在',
duplicate: '相同内容的文件已存在',
'no-change': '内容与现有文件一致',
duplicateversions: '内容与已有历史版本一致',
'was-deleted': '这个文件名曾被删除',
'duplicate-archive': '相同内容的文件曾被删除',
badfilename: '文件名需要调整',
'bad-prefix': '文件名前缀不符合站点要求',
'large-file': '文件大小触发站点警告'
};
return Object.keys(warnings || {}).map(function (key) {
var value = warnings[key];
var detail = typeof value === 'string' ? value : JSON.stringify(value);
return (labels[key] || '站点警告') + ' [' + key + ']' + (detail ? ':' + detail.slice(0, 500) : '');
}).join('\n') || '服务器返回上传警告。';
}
function pageText() {
var categories = [];
ui.categories.value.split(/\r?\n/).forEach(function (line) {
var name = line.trim();
if (!name) { return; }
[categoryNamespace, 'Category', '分类', '分類'].forEach(function (ns) {
if (name.slice(0, ns.length + 1).toLowerCase() === (ns + ':').toLowerCase()) {
name = name.slice(ns.length + 1).trim();
}
});
if (!name || /[\[\]{}|<>#\u0000-\u001f\u007f]/.test(name)) {
throw new Error('分类请每行只填一个名称,不要填写 [[Category:…]] 等维基代码。');
}
if (categories.indexOf(name) === -1) { categories.push(name); }
});
var text = ui.description.value.trim();
if (categories.length) {
text += (text ? '\n\n' : '') + categories.map(function (name) {
return '[[' + categoryNamespace + ':' + name + ']]';
}).join('\n');
}
return text;
}
async function uploadOne(item, text) {
var name = item.input.value.trim();
if (!name || /[\\/:#<>\[\]|{}\u0000-\u001f\u007f]/.test(name) || !/\.[^.\s]+$/.test(name)) {
throw new Error('目标名称需包含扩展名,且不能包含路径、命名空间或非法字符(如 / : # [ ])。');
}
if (!item.file.size) {
throw new Error('文件为空,或浏览器无法读取。请使用文件选择器选择普通文件,不要拖入文件夹。');
}
item.input.value = name;
setState(item, 'uploading', '正在检查目标文件名……');
item.progress.removeAttribute('value');
// 同时检查本地文件说明页及共享仓库文件;避免无意遮蔽共享文件。
var check = await request(api.get({
action: 'query', titles: fileNamespace + ':' + name,
prop: 'imageinfo', iiprop: 'timestamp'
}));
var page = check.query && check.query.pages && check.query.pages[0];
if (!page || page.invalid || page.ns !== 6) {
throw new Error('服务器不接受这个文件名,或未能返回有效的文件页面信息。');
}
if (!page.missing || (page.imageinfo && page.imageinfo.length) ||
(page.imagerepository && page.imagerepository !== '')) {
throw warningError('目标文件或文件说明页已存在(可能来自共享仓库)。未发送文件;请改名或使用已有文件。');
}
// 使用服务器规范化的首字母、空格与名称。
name = page.title.slice(page.title.indexOf(':') + 1);
item.input.value = name;
item.progress.value = 0;
item.status.textContent = '正在获取令牌并上传……';
var result = await request(api.postWithToken('csrf', {
action: 'upload', filename: name, file: item.file,
text: text, comment: '使用批量上传页面上传', watchlist: 'nochange'
// 刻意不传 ignorewarnings,也不根据 filekey 自动确认警告。
}, {
contentType: 'multipart/form-data', // mw.Api 会构造 FormData 并设置 boundary
timeout: 0,
xhr: function () {
var xhr = jQuery.ajaxSettings.xhr();
if (xhr.upload) {
xhr.upload.addEventListener('progress', function (event) {
if (event.lengthComputable) {
var percent = Math.min(100, Math.round(event.loaded / event.total * 100));
item.progress.value = percent;
item.status.textContent = percent < 100 ? '正在传输 ' + percent + '%' : '传输完成,等待服务器确认……';
}
});
}
return xhr;
}
}));
var upload = result && result.upload;
// 只以 API 的 Success 为成功;HTTP 200 或传输到 100% 都不是成功确认。
if (upload && upload.result === 'Success') {
item.input.value = upload.filename || name;
setState(item, 'success', '成功');
return;
}
if (upload && (upload.result === 'Warning' || upload.warnings)) {
throw warningError(warningText(upload.warnings) + '\n本工具没有确认警告,未完成正式发布。');
}
var unknown = new Error('服务器返回了未识别的上传结果。请先检查目标文件页面,不要直接重复上传。');
unknown.stopQueue = true;
throw unknown;
}
async function run() {
if (busy || !ready || !items.some(function (item) { return item.state === 'queued'; })) { return; }
var text;
try { text = pageText(); }
catch (error) { message(error.message); return; }
busy = true;
pauseRequested = false;
refresh();
message('正在逐个上传。请保持页面打开;“本项完成后暂停”不会中断已发出的请求。');
try {
for (var i = 0; i < items.length; i++) {
if (pauseRequested) { break; }
var item = items[i];
if (item.state !== 'queued') { continue; }
try {
await uploadOne(item, text);
} catch (error) {
setState(item, error.isWarning ? 'warning' : 'failed',
(error.message || String(error)) + (error.code ? ' [' + error.code + ']' : ''));
if (error.stopQueue) { pauseRequested = true; }
}
if (!pauseRequested && i < items.length - 1) { await sleep(CONFIG.intervalMs); }
}
} finally {
busy = false;
refresh();
message(pauseRequested ?
'队列已暂停。当前请求已返回,未处理项仍在队列中。请检查错误或账号状态,再点击“开始上传”继续;失败项需手动重试。' :
'本轮处理结束。请检查各项状态;成功项不会被本工具再次上传。警告项未自动确认,失败项可在排查后重试。');
}
}
ui.choose.addEventListener('click', function () { ui.files.click(); });
ui.files.addEventListener('change', function () { addFiles(ui.files.files); ui.files.value = ''; });
['dragenter', 'dragover'].forEach(function (type) {
ui.drop.addEventListener(type, function (event) {
event.preventDefault();
if (ready && !busy) { ui.drop.classList.add('bu-over'); }
});
});
ui.drop.addEventListener('dragleave', function (event) {
if (!ui.drop.contains(event.relatedTarget)) { ui.drop.classList.remove('bu-over'); }
});
ui.drop.addEventListener('drop', function (event) {
event.preventDefault();
ui.drop.classList.remove('bu-over');
if (event.dataTransfer) { addFiles(event.dataTransfer.files); }
});
ui.applyPrefix.addEventListener('click', function () {
if (busy || !ready) { return; }
items.forEach(function (item) {
if (item.state !== 'success') { item.input.value = ui.prefix.value + item.file.name; resetItem(item); }
});
message('已按“前缀 + 原始文件名”更新未成功项。请检查后再开始上传。');
});
ui.start.addEventListener('click', function () { run(); });
ui.pause.addEventListener('click', function () {
pauseRequested = true;
message('将在当前文件处理完成后暂停;当前请求不会中止。');
refresh();
});
ui.retry.addEventListener('click', function () {
if (busy || !ready) { return; }
items.forEach(function (item) {
if (item.state === 'failed' || item.state === 'warning') { resetItem(item); }
});
run();
});
ui.clear.addEventListener('click', function () {
if (busy) { return; }
items = [];
ui.rows.textContent = '';
message('已清空本地列表。此前上传到本站的文件不会被删除。');
refresh();
});
window.addEventListener('beforeunload', function (event) {
if (busy || items.some(function (item) { return item.state !== 'success'; })) {
event.preventDefault();
event.returnValue = ''; // 浏览器显示自己的离开页面提示文字
}
});
if (!window.File || !window.FormData || !window.Promise) {
message('浏览器不支持所需的文件上传功能,请使用较新的 Chrome、Firefox、Edge 或 Safari。');
return;
}
if (!userName) {
message('请先登录,再打开此页面。本工具不提供匿名上传。');
var login = document.createElement('a');
login.href = mw.util.getUrl('Special:UserLogin', { returnto: mw.config.get('wgPageName').replace(/_/g, ' ') });
login.textContent = ' 前往登录';
ui.message.appendChild(login);
return;
}
api = new mw.Api({ parameters: {
formatversion: 2, errorformat: 'plaintext',
assert: 'user', assertuser: userName, maxlag: 5
} });
request(api.get({ action: 'query', meta: 'userinfo', uiprop: 'rights' })).then(function (data) {
var info = data.query && data.query.userinfo;
if (!info || !Array.isArray(info.rights)) {
throw new Error('未能确认账号的上传权限,请刷新或联系管理员。');
}
if (info.rights.indexOf('upload') === -1) {
message('当前账号没有 upload 权限。界面脚本不能授予上传权限,请联系站点管理员。');
return;
}
ready = true;
message('当前账号:' + userName + '。请选择文件。建议先用 2~3 个小文件测试。');
refresh();
}).catch(function (error) {
message('初始化失败:' + error.message + '。请检查登录状态或 API 是否可用,再刷新页面。');
});
}
}());