首页
社区
新闻
视频博客
锦标赛
排行榜
接受挑战
维基
论坛
navigation
首页
最近更改
随机页面
MediaWiki帮助
TOOLBOX
链入页面
相关更改
特殊页面
页面信息
cn
RU
EN
DE
个人工具
登录
查看“︁MediaWiki:BatchUpload.js”︁的源代码
来自3D坦克-WIKI
←
MediaWiki:BatchUpload.js
因为以下原因,您没有权限编辑该页面:
您请求的操作仅限属于该用户组的用户执行:
用户
此页面为本wiki上的软件提供界面文本,并受到保护以防止滥用。如欲修改所有wiki的翻译,请访问
translatewiki.net
上的MediaWiki本地化项目。
您无权编辑此JavaScript页面,因为编辑此页面可能会影响所有访问者。
您可以查看和复制此页面的源代码。
/* * MediaWiki 批量上传页面 v2.0.0 * 保存到 MediaWiki:BatchUpload.js;不要加 <script> 标签。 * 保持原入口 Project:批量上传、容器 #mw-batch-upload 不变。 * 10,000 项队列 / 有限并发 / 分页 / duplicate 自动确认 / 限流退避 / CSV 导出。 * 仅使用本站 API 和当前账号;不保存密码,不将文件发送到第三方。 */ (function () { 'use strict'; var CONFIG = { namespace: 4, pageTitle: '批量上传', containerId: 'mw-batch-upload', maxFiles: 10000, pageSize: 50, defaultConcurrency: 3, maxConcurrency: 6, minStartIntervalMs: 500, // 全局 API 请求启动间隔;不是“每个 worker 各自间隔” ignoreContentDuplicates: true, maxRateRetries: 3, // 每个文件的限流/负载错误最多自动重试 3 次 rateBackoffBaseMs: 30000, minRetryIntervalMs: 1000, // 限流后至少将本轮请求间隔提高到此值 maxlagBackoffBaseMs: 5000, renderIntervalMs: 100 }; 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 = '2.0.0'; 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);--bu-muted:var(--color-subtle,#54595d);font-size:14px;line-height:1.6;color:var(--color-base,#202122);margin:1em 0;min-width:0}', '#mw-batch-upload *{box-sizing:border-box}', '#mw-batch-upload .bu-panel{background:var(--background-color-base,#fff);border:1px solid var(--bu-border);border-radius:8px;padding:20px;min-width:0}', '#mw-batch-upload .bu-heading{font-size:22px;font-weight:700;margin:0 0 4px}', '#mw-batch-upload .bu-version{font-size:12px;font-weight:400;margin-left:8px;color:var(--bu-muted)}', '#mw-batch-upload .bu-note{font-size:13px;color:var(--bu-muted);margin:6px 0 12px}', '#mw-batch-upload .bu-drop{padding:22px 12px;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 label{display:block;font-weight:600}', '#mw-batch-upload input[type=text],#mw-batch-upload input[type=number],#mw-batch-upload select,#mw-batch-upload textarea{max-width:100%;width:100%;padding:8px;border:1px solid var(--bu-border);border-radius:4px;background:var(--background-color-base,#fff);color:inherit;font:inherit;font-weight:400}', '#mw-batch-upload textarea{min-height:96px;resize:vertical}', '#mw-batch-upload .bu-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:14px;margin:14px 0}', '#mw-batch-upload .bu-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,170px),1fr));gap:14px;margin:14px 0}', '#mw-batch-upload .bu-inline{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0 12px}', '#mw-batch-upload .bu-inline input{flex:1;min-width:100px}', '#mw-batch-upload .bu-check{display:flex;gap:8px;align-items:flex-start;font-weight:400}', '#mw-batch-upload .bu-check input{margin-top:6px}', '#mw-batch-upload button{padding:7px 12px;border:1px solid var(--bu-border);border-radius:4px;background:var(--background-color-interactive,#eaecf0);color:inherit;font:inherit;font-weight:600;cursor:pointer;white-space:normal}', '#mw-batch-upload .bu-primary{background:#36c;color:#fff;border-color:#36c}', '#mw-batch-upload button:disabled{opacity:.5;cursor:not-allowed}', '#mw-batch-upload input:disabled,#mw-batch-upload textarea:disabled,#mw-batch-upload select:disabled{opacity:.7}', '#mw-batch-upload button:focus-visible,#mw-batch-upload input:focus-visible,#mw-batch-upload select:focus-visible,#mw-batch-upload textarea:focus-visible{outline:2px solid #36c;outline-offset:2px}', '#mw-batch-upload .bu-toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}', '#mw-batch-upload .bu-message{padding:10px 12px;border-left:3px solid #36c;background:var(--background-color-interactive-subtle,#f8f9fa);margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}', '#mw-batch-upload .bu-summary{font-weight:600;overflow-wrap:anywhere}', '#mw-batch-upload .bu-runtime{font-size:13px;min-height:1.6em;color:var(--bu-muted)}', '#mw-batch-upload progress{display:block;width:100%;height:8px;accent-color:#36c;margin:8px 0}', '#mw-batch-upload .bu-filter{display:flex;flex-wrap:wrap;align-items:end;gap:10px;margin:18px 0 8px}', '#mw-batch-upload .bu-filter label:first-child{flex:2;min-width:160px}', '#mw-batch-upload .bu-filter label:nth-child(2){flex:1;min-width:135px}', '#mw-batch-upload .bu-table-region{container-type:inline-size;container-name:buqueue}', '#mw-batch-upload table{width:100%;border-collapse:collapse;table-layout:fixed}', '#mw-batch-upload th,#mw-batch-upload td{padding:10px 8px;border-bottom:1px solid var(--bu-border);text-align:left;vertical-align:top;overflow-wrap:anywhere}', '#mw-batch-upload th{background:var(--background-color-interactive-subtle,#f8f9fa)}', '#mw-batch-upload th:nth-child(1){width:23%}#mw-batch-upload th:nth-child(2){width:29%}#mw-batch-upload th:nth-child(3){width:35%}#mw-batch-upload th:nth-child(4){width:13%}', '#mw-batch-upload .bu-size{font-size:12px;color:var(--bu-muted)}', '#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 tr[data-state=skipped] .bu-status{color:var(--bu-muted)}', '#mw-batch-upload details{margin:6px 0;font-size:12px}', '#mw-batch-upload summary{cursor:pointer;color:var(--bu-muted)}', '#mw-batch-upload pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;margin:5px 0;padding:8px;border:1px solid var(--bu-border);background:var(--background-color-interactive-subtle,#f8f9fa);color:inherit}', '#mw-batch-upload .bu-row-actions{display:flex;gap:6px;flex-wrap:wrap}', '#mw-batch-upload .bu-row-actions button{padding:4px 8px;font-size:12px}', '#mw-batch-upload .bu-empty{text-align:center;padding:24px;color:var(--bu-muted)}', '#mw-batch-upload .bu-pages{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-top:12px}', '#mw-batch-upload [hidden]{display:none!important}', '@container buqueue (max-width:620px){#mw-batch-upload table,#mw-batch-upload tbody,#mw-batch-upload tr,#mw-batch-upload td{display:block;width:100%}#mw-batch-upload thead{display:none}#mw-batch-upload tr{padding:12px 0;border-bottom:1px solid var(--bu-border)}#mw-batch-upload td{border:0;padding:4px 0}#mw-batch-upload td::before{content:attr(data-label);display:block;font-size:12px;color:var(--bu-muted)}}', '@media(max-width:620px){#mw-batch-upload .bu-panel{padding:12px}#mw-batch-upload table,#mw-batch-upload tbody,#mw-batch-upload tr,#mw-batch-upload td{display:block;width:100%}#mw-batch-upload thead{display:none}#mw-batch-upload tr{padding:12px 0;border-bottom:1px solid var(--bu-border)}#mw-batch-upload td{border:0;padding:4px 0}#mw-batch-upload td::before{content:attr(data-label);display:block;font-size:12px;color:var(--bu-muted)}}' ].join('\n')); // 这里只插入固定界面;文件名、输入、API 信息一律通过 textContent/value 写入。 root.innerHTML = ` <section class="bu-panel" aria-label="批量上传文件"> <div class="bu-heading">批量上传文件 <span class="bu-version"></span></div> <p class="bu-note">内容重复可自动确认,已有同名文件会跳过。大批量任务建议使用电脑并保持此页打开。</p> <div data-el="message" class="bu-message" role="status" aria-live="polite">正在检查登录状态和上传权限……</div> <div data-el="drop" class="bu-drop"> <button type="button" data-el="choose" disabled>选择多个文件</button> 或将文件拖到这里 <input type="file" multiple hidden data-el="files" aria-label="选择待上传文件" disabled> <div class="bu-note" data-el="queueLimit"></div> </div> <div class="bu-settings"> <label>并发文件数 <select data-el="concurrency" disabled></select></label> <label>API 请求启动间隔(毫秒)<input type="number" data-el="gap" min="0" max="60000" step="100" disabled></label> </div> <label class="bu-check"><input type="checkbox" data-el="duplicates" disabled><span>全局自动确认内容重复(duplicate)警告<br><span class="bu-note">不自动确认重名、曾删除、非法文件名等其他警告;不修改已有文件说明。</span></span></label> <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 data-el="applyPrefix" type="button" disabled>应用到未成功项</button></div> <p class="bu-note">应用前缀会用“前缀 + 原始文件名”替换未成功项的手工改名,并将它们重新排队;不会叠加前缀。</p> <div class="bu-toolbar"> <button type="button" data-el="start" class="bu-primary" disabled>开始 / 继续上传</button> <button type="button" data-el="pause" disabled>暂停新请求</button> <button type="button" data-el="retry" disabled>失败项重新排队</button> <button type="button" data-el="export" disabled>导出结果 CSV</button> <button type="button" data-el="clear" disabled>清空列表</button> </div> <div data-el="summary" class="bu-summary" role="status" aria-live="polite">尚未选择文件</div> <div data-el="runtime" class="bu-runtime"></div> <progress data-el="overall" max="1" value="0" aria-label="已处理文件数量"></progress> <div class="bu-filter"> <label>筛选文件名<input type="text" data-el="search" placeholder="本地名称或目标名称"></label> <label>状态<select data-el="filter"><option value="all">全部</option><option value="uploading">正在处理 / 等待重试</option><option value="queued">待处理</option><option value="success">成功</option><option value="skipped">跳过(同名已存在)</option><option value="warning">其他警告</option><option value="failed">失败</option></select></label> </div> <div class="bu-table-region"> <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 data-el="empty" class="bu-empty">选择文件后显示上传队列。</div> </div> <div class="bu-pages"><button type="button" data-el="prev">上一页</button><span data-el="pageInfo"></span><button type="button" data-el="next">下一页</button></div> <p class="bu-note">列表分页不影响后台队列,所有待处理项都会依次调度。100% 传输不等于成功;以服务器确认结果为准。清空列表不删除站内文件。</p> <p class="bu-note">暂停不强行中断已发出的请求,仍可能有少量在途文件完成。关闭或刷新会丢失待传队列;不支持分片或跨刷新断点续传。并发与间隔都不能绕过本站限流。</p> </section>`; var ui = {}; root.querySelectorAll('[data-el]').forEach(function (el) { ui[el.dataset.el] = el; }); for (var n = 1; n <= CONFIG.maxConcurrency; n++) { var option = document.createElement('option'); option.value = String(n); option.textContent = n + ' 路'; ui.concurrency.appendChild(option); } ui.concurrency.value = String(CONFIG.defaultConcurrency); ui.gap.value = String(CONFIG.minStartIntervalMs); ui.duplicates.checked = CONFIG.ignoreContentDuplicates; ui.queueLimit.textContent = '可分批添加,队列上限 ' + CONFIG.maxFiles.toLocaleString() + ' 项;只渲染当前页,不生成数千行 DOM。不支持文件夹。'; var items = []; var serial = 0; var counts = { queued: 0, uploading: 0, success: 0, skipped: 0, warning: 0, failed: 0 }; var labels = { queued: '待处理', uploading: '处理中', success: '成功', skipped: '已跳过', warning: '警告', failed: '失败' }; var ready = false; var busy = false; var session = null; var api; var activeClaims = new Map(); var cooldownUntil = 0; var cooldownReason = ''; var pageNumber = 1; var queueVersion = 0; var viewVersion = -1; var viewKey = ''; var filtered = []; var renderedIds = ''; var rowMap = new Map(); var renderTimer = null; var runtimeTimer = null; var searchTimer = null; var userName = mw.config.get('wgUserName'); var namespaces = mw.config.get('wgFormattedNamespaces') || {}; var fileNamespace = namespaces[6] || 'File'; var categoryNamespace = namespaces[14] || 'Category'; var number = function (v) { return v.toLocaleString(); }; function setText(el, text) { if (el.textContent !== text) { el.textContent = text; } } function notice(text) { setText(ui.message, text); } function fileUrl(name) { return mw.util.getUrl(fileNamespace + ':' + name); } function sizeText(bytes) { if (bytes < 1024) { return bytes + ' B'; } if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KiB'; } if (bytes < 1073741824) { return (bytes / 1048576).toFixed(1) + ' MiB'; } return (bytes / 1073741824).toFixed(2) + ' GiB'; } function scheduleRender() { if (renderTimer === null) { renderTimer = window.setTimeout(function () { renderTimer = null; render(); }, CONFIG.renderIntervalMs); } } function flushRender() { if (renderTimer !== null) { window.clearTimeout(renderTimer); renderTimer = null; } render(); } function state(item, name, text, detail, code) { if (item.state !== name) { counts[item.state]--; counts[name]++; item.state = name; } item.message = text; item.detail = detail || ''; item.code = code || ''; if (name === 'queued') { item.progress = 0; } if (name === 'success') { item.progress = 100; item.stashKey = null; } queueVersion++; scheduleRender(); } function phase(item, text, progress) { item.message = text; if (progress !== undefined) { item.progress = progress; } scheduleRender(); } function resetItem(item, discardStash) { if (item.state === 'success') { return; } if (discardStash) { item.stashKey = null; } item.autoConfirmed = false; state(item, 'queued', '等待上传'); } function getView() { var term = ui.search.value.trim().toLowerCase(); var filter = ui.filter.value; var key = term + '\u0000' + filter; if (viewVersion !== queueVersion || key !== viewKey) { filtered = items.filter(function (item) { return (filter === 'all' || item.state === filter) && (!term || item.file.name.toLowerCase().includes(term) || item.target.toLowerCase().includes(term)); }); viewVersion = queueVersion; viewKey = key; } return filtered; } function makeRow(item) { var row = document.createElement('tr'); row.dataset.id = String(item.id); row.innerHTML = '<td data-label="本地文件"><div class="bu-name"></div><div class="bu-size"></div></td>' + '<td data-label="目标文件名"><input type="text"></td>' + '<td data-label="状态 / 传输进度"><div class="bu-status"></div><progress max="100" value="0" hidden></progress><details hidden><summary>完整信息</summary><pre></pre></details><a target="_blank" rel="noopener noreferrer" hidden></a></td>' + '<td data-label="操作"><div class="bu-row-actions"><button type="button" data-action="requeue">重新排队</button><button type="button" data-action="remove" title="仅移出队列,不删除站内文件">移除</button></div></td>'; var refs = { row: row, name: row.querySelector('.bu-name'), size: row.querySelector('.bu-size'), input: row.querySelector('input'), status: row.querySelector('.bu-status'), progress: row.querySelector('progress'), details: row.querySelector('details'), pre: row.querySelector('pre'), link: row.querySelector('a'), retry: row.querySelector('[data-action=requeue]'), remove: row.querySelector('[data-action=remove]') }; refs.name.textContent = item.file.name; refs.size.textContent = '#' + item.id + ' · ' + sizeText(item.file.size); refs.input.setAttribute('aria-label', item.file.name + ' 的目标文件名'); refs.progress.setAttribute('aria-label', item.file.name + ' 的传输进度'); refs.input.addEventListener('input', function () { if (busy || item.state === 'success') { return; } item.target = refs.input.value; resetItem(item, true); }); refs.retry.addEventListener('click', function () { if (busy || !ready) { return; } resetItem(item, true); notice('已将该项重新排队。确认目标名称后,点击“开始 / 继续上传”。'); flushRender(); }); refs.remove.addEventListener('click', function () { if (busy) { return; } counts[item.state]--; items = items.filter(function (other) { return other.id !== item.id; }); queueVersion++; flushRender(); }); return refs; } function updateRow(refs, item) { refs.row.dataset.state = item.state; if (document.activeElement !== refs.input || busy) { refs.input.value = item.target; } refs.input.disabled = busy || !ready || item.state === 'success'; setText(refs.status, item.message); refs.progress.hidden = item.state !== 'uploading' && item.state !== 'success'; if (item.progress === null) { refs.progress.removeAttribute('value'); } else { refs.progress.value = item.progress; } refs.details.hidden = !item.detail; setText(refs.pre, item.detail); refs.link.hidden = !['success', 'skipped', 'warning', 'failed'].includes(item.state); refs.link.href = fileUrl(item.target.trim()); setText(refs.link, item.state === 'success' ? '查看文件' : '检查目标页面'); refs.retry.hidden = !['failed', 'warning', 'skipped'].includes(item.state); refs.retry.disabled = busy || !ready; refs.remove.disabled = busy; } function render() { var locked = busy || !ready; ['choose', 'files', 'description', 'categories', 'prefix', 'concurrency', 'gap', 'duplicates'].forEach(function (key) { ui[key].disabled = locked; }); ui.applyPrefix.disabled = locked || items.length === counts.success; ui.start.disabled = locked || !counts.queued; ui.pause.disabled = !busy || !session || session.stopped; ui.retry.disabled = locked || !counts.failed; ui.export.disabled = !items.length; ui.clear.disabled = busy || !items.length; var done = counts.success + counts.skipped + counts.warning + counts.failed; setText(ui.summary, items.length ? '共 ' + number(items.length) + ' 项 · 成功 ' + number(counts.success) + ' · 跳过 ' + number(counts.skipped) + ' · 警告 ' + number(counts.warning) + ' · 失败 ' + number(counts.failed) + ' · 处理中 ' + counts.uploading + ' · 待处理 ' + number(counts.queued) : '尚未选择文件'); ui.overall.max = Math.max(items.length, 1); ui.overall.value = done; var seconds = Math.max(0, Math.ceil((cooldownUntil - Date.now()) / 1000)); var runtime = '已处理 ' + number(done) + ' / ' + number(items.length) + ' 项(按文件数,不是字节数)'; if (busy && session) { runtime += ' · 并发上限 ' + session.settings.concurrency + ' · 当前请求间隔 ' + session.effectiveGap + ' ms'; } if (seconds > 0) { runtime += ' · ' + cooldownReason + ',新请求至少等待 ' + seconds + ' 秒'; } setText(ui.runtime, runtime); var view = getView(); var pages = Math.max(1, Math.ceil(view.length / CONFIG.pageSize)); pageNumber = Math.min(Math.max(pageNumber, 1), pages); var offset = (pageNumber - 1) * CONFIG.pageSize; var visible = view.slice(offset, offset + CONFIG.pageSize); var ids = visible.map(function (item) { return item.id; }).join(','); if (ids !== renderedIds) { renderedIds = ids; rowMap.clear(); ui.rows.textContent = ''; var fragment = document.createDocumentFragment(); visible.forEach(function (item) { var refs = makeRow(item); rowMap.set(item.id, refs); fragment.appendChild(refs.row); }); ui.rows.appendChild(fragment); } visible.forEach(function (item) { updateRow(rowMap.get(item.id), item); }); ui.empty.hidden = visible.length > 0; setText(ui.empty, items.length ? '没有符合筛选条件的文件。' : '选择文件后显示上传队列。'); ui.prev.disabled = pageNumber <= 1; ui.next.disabled = pageNumber >= pages; setText(ui.pageInfo, '第 ' + pageNumber + ' / ' + pages + ' 页 · 筛选 ' + number(view.length) + ' 项 · 每页 ' + CONFIG.pageSize + ' 项'); } function addFiles(list) { if (busy || !ready) { return; } var added = 0; var excess = 0; Array.from(list || []).forEach(function (file) { if (!(file instanceof window.File)) { return; } if (items.length >= CONFIG.maxFiles) { excess++; return; } items.push({ id: ++serial, file: file, target: ui.prefix.value + file.name, state: 'queued', message: '等待上传', detail: '', code: '', progress: 0, stashKey: null, autoConfirmed: false }); counts.queued++; added++; }); queueVersion++; notice('已添加 ' + number(added) + ' 个文件。检查文件名、来源与授权说明后再开始。' + (excess ? '\n另有 ' + number(excess) + ' 个未添加:队列上限为 ' + number(CONFIG.maxFiles) + ' 项。' : '') + '\n仅将 File 引用加入队列,不会提前把全部文件读成内存数组或生成缩略图。'); flushRender(); } 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.includes(name)) { 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; } function readSettings() { var concurrency = Number(ui.concurrency.value); var gap = Number(ui.gap.value); if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > CONFIG.maxConcurrency) { throw new Error('并发数需在 1~' + CONFIG.maxConcurrency + ' 之间。'); } if (!Number.isFinite(gap) || gap < 0 || gap > 60000) { throw new Error('启动间隔需在 0~60000 毫秒之间。'); } return { concurrency: concurrency, gap: Math.round(gap), text: pageText(), duplicates: ui.duplicates.checked }; } function userError(text) { var error = new Error(text); error.userInput = true; return error; } function stopError() { var error = new Error('队列已暂停'); error.queueStopped = true; return error; } function assertRunning(run) { if (run.stopped) { throw stopError(); } } function wait(ms, run) { assertRunning(run); return new Promise(function (resolve, reject) { var timer; function stop() { window.clearTimeout(timer); run.controller.signal.removeEventListener('abort', stop); reject(stopError()); } run.controller.signal.addEventListener('abort', stop, { once: true }); timer = window.setTimeout(function () { run.controller.signal.removeEventListener('abort', stop); resolve(); }, Math.max(0, ms)); }); } function stopRun(reason) { if (!session || session.stopped) { return; } session.stopped = true; session.stopReason = reason; session.controller.abort(); // 只取消本地调度/退避等待,不传给 AJAX,不中断在途写请求 notice(reason + '\n已停止发起新请求;正在传输的请求会等待服务器返回。'); flushRender(); } // 只串行化“开始请求”的时刻,而不是串行等待请求结束,因此仍有有限并发。 function gate(run) { var next = run.dispatchTail.then(async function () { while (true) { assertRunning(run); var due = Math.max(cooldownUntil, run.nextRequestAt); var delay = due - Date.now(); if (delay <= 0) { break; } await wait(delay, run); } assertRunning(run); run.nextRequestAt = Date.now() + run.effectiveGap; }); run.dispatchTail = next.catch(function () {}); return next; } function retryAfter(xhr) { if (!xhr || typeof xhr.getResponseHeader !== 'function') { return 0; } var value = xhr.getResponseHeader('Retry-After'); if (!value) { return 0; } var seconds = Number(value); if (Number.isFinite(seconds) && seconds >= 0) { return seconds * 1000; } var date = Date.parse(value); return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; } function apiError(code, data, xhr) { xhr = xhr || (data && data.xhr); var body = data; if (code === 'http' && xhr && xhr.responseJSON) { body = xhr.responseJSON; } var raw = body && (body.error || (body.errors && body.errors[0])); var actualCode = (raw && raw.code) || code || 'unknown'; var original = raw && (raw.info || raw.text || raw['*']); var error = new Error(original || String(actualCode)); error.code = String(actualCode); error.detail = original || ''; error.httpStatus = xhr && xhr.status; error.retryAfterMs = retryAfter(xhr); error.rateLimited = /^(?:apierror-)?(?:ratelimited|actionthrottled|maxlag)$/.test(error.code) || error.httpStatus === 429; error.stopQueue = /assert|badtoken|notloggedin|read.?only|upload.*disabled|permissiondenied|blocked|noapiwrite|writeapidenied/i.test(error.code) || /^(?:apierror-)?(?:lockmanager|filebackend)-/i.test(error.code) || code === 'http' || code === 'ok-but-empty'; if (/^(?:apierror-)?(?:lockmanager|filebackend)-/i.test(error.code)) { error.message = '服务器文件锁 / 存储故障,请联系管理员。'; } else if (error.rateLimited) { error.message = /maxlag/.test(error.code) ? '服务器负载过高,需要等待。' : '触发服务器限流,需要等待。'; } else if (code === 'http' || code === 'ok-but-empty') { error.message = '网络 / HTTP 响应异常,上传结果可能未确认;先检查目标页面再重试。'; error.detail = (original || '') + (error.httpStatus ? '\nHTTP ' + error.httpStatus : ''); } else { error.message = '请求失败:' + error.code + '(展开完整信息查看原因)'; } return error; } function request(jqPromise) { return new Promise(function (resolve, reject) { jqPromise.done(resolve).fail(function (code, data, result, xhr) { reject(apiError(code, data, xhr)); }); }); } async function call(run, factory) { await gate(run); assertRunning(run); return request(factory()); } function backoff(error, attempt, run) { if (!/maxlag/.test(error.code)) { run.effectiveGap = Math.max(run.effectiveGap, Math.min(60000, Math.max(CONFIG.minRetryIntervalMs, run.effectiveGap * 2))); } var base = /maxlag/.test(error.code) ? CONFIG.maxlagBackoffBaseMs : CONFIG.rateBackoffBaseMs; var delay = Math.max(error.retryAfterMs, Math.min(base * Math.pow(2, attempt - 1), 300000)); cooldownUntil = Math.max(cooldownUntil, Date.now() + delay); cooldownReason = /maxlag/.test(error.code) ? '服务器负载退避' : '服务器限流退避'; scheduleRender(); } async function inspectTarget(item, run) { var name = item.target.trim(); if (!name || /[\\/:#<>\[\]|{}\u0000-\u001f\u007f]/.test(name) || !/\.[^.\s]+$/.test(name)) { throw userError('目标名需包含扩展名,不能含路径、命名空间或非法字符(如 / : # [ ])。'); } if (!item.file.size) { throw userError('文件为空或不可读取。请选普通文件,不要拖入文件夹。'); } phase(item, '检查目标文件名……', null); var result = await call(run, function () { return api.get({ action: 'query', titles: fileNamespace + ':' + name, prop: 'imageinfo', iiprop: 'timestamp', maxage: 0 }); }); var page = result.query && result.query.pages && result.query.pages[0]; if (!page || page.invalid || page.ns !== 6 || typeof page.title !== 'string') { throw userError('服务器未返回有效的文件页面信息,请检查目标文件名。'); } item.target = page.title.slice(page.title.indexOf(':') + 1); queueVersion++; if (!page.missing || (page.imageinfo && page.imageinfo.length) || (page.imagerepository && page.imagerepository !== '')) { state(item, 'skipped', '已有同名文件 / 说明页,跳过;未核对其内容。', '也可能是共享仓库文件。本工具不会主动替换该目标。若要保留另一个文件,请修改目标名称后重新排队。'); return false; } var owner = activeClaims.get(page.title); if (owner !== undefined && owner !== item.id) { state(item, 'warning', '队列内另一项正在处理同一个规范化文件名,未上传。', '请使用不同的目标名称,或等待另一项完成后检查目标页面。', 'queue-name-conflict'); return false; } activeClaims.set(page.title, item.id); item.claimedTitle = page.title; return true; } function warningKeys(upload) { return upload && upload.warnings && typeof upload.warnings === 'object' && !Array.isArray(upload.warnings) ? Object.keys(upload.warnings) : []; } function recordWarning(item, upload, extra) { var keys = warningKeys(upload); var detail = JSON.stringify(upload.warnings || {}, null, 2); state(item, 'warning', extra || ('未自动确认服务器警告:' + (keys.join(', ') || '未知警告')), (detail.length > 6000 ? detail.slice(0, 6000) + '\n……信息已截断' : detail) + '\n此项未完成正式发布。', keys.join('|')); } function uploaded(item, result, autoConfirmed) { var upload = result && result.upload; if (upload && upload.result === 'Success') { item.target = upload.filename || item.target; item.autoConfirmed = autoConfirmed; state(item, 'success', autoConfirmed ? '成功 · 内容重复已自动确认' : '成功'); return true; } return false; } function unknownResult() { var error = new Error('服务器返回未识别的上传结果。请先检查目标页面,不要直接重复上传。'); error.stopQueue = true; error.code = 'unknown-upload-result'; return error; } function uploadParameters(item, run) { return { action: 'upload', filename: item.target, text: run.settings.text, comment: '使用批量上传页面上传', watchlist: 'nochange' }; } function binaryOptions(item) { return { contentType: 'multipart/form-data', timeout: 0, xhr: function () { var xhr = jQuery.ajaxSettings.xhr(); if (xhr.upload) { xhr.upload.addEventListener('progress', function (event) { if (event.lengthComputable && item.state === 'uploading') { var percent = Math.min(100, Math.round(event.loaded / event.total * 100)); phase(item, percent < 100 ? '传输中 ' + percent + '%' : '传输完成,等待服务器确认……', percent); } }); } return xhr; } }; } async function attemptUpload(item, run) { if (!await inspectTarget(item, run)) { return; } var params = uploadParameters(item, run); var fromStash = typeof item.stashKey === 'string' && !!item.stashKey; if (fromStash) { params.filekey = item.stashKey; } else { params.file = item.file; } phase(item, fromStash ? '重新检查暂存文件的发布条件……' : '等待发送文件……', fromStash ? null : 0); // 第一次上传 / 重试暂存文件时不忽略任何警告,先获取完整警告集合。 var result = await call(run, function () { return api.postWithToken('csrf', params, fromStash ? { timeout: 0 } : binaryOptions(item)); }); if (uploaded(item, result, false)) { return; } var upload = result && result.upload; if (!upload || (upload.result !== 'Warning' && !upload.warnings)) { throw unknownResult(); } if (typeof upload.filekey === 'string' && upload.filekey) { item.stashKey = upload.filekey; } var keys = warningKeys(upload); var onlyDuplicate = keys.length > 0 && keys.every(function (key) { return key === 'duplicate'; }); if (!run.settings.duplicates || !onlyDuplicate) { recordWarning(item, upload); return; } if (!item.stashKey) { recordWarning(item, upload, '只有内容重复警告,但服务器未返回 filekey,无法安全自动确认。'); return; } // 自动处理 duplicate,而不是对首次请求无条件设置 ignorewarnings=1。 // 再查同名目标,并用本页互斥表防止多个 worker 同时发布同一名称。 // 跨客户端竞态仍需服务器 reupload 权限控制,详见 README;API 没有逐项忽略参数。 phase(item, '内容重复:自动确认前再次检查目标……', null); if (!await inspectTarget(item, run)) { return; } phase(item, '正在自动确认内容重复并发布……', null); var confirm = uploadParameters(item, run); confirm.filekey = item.stashKey; confirm.ignorewarnings = true; var committed = await call(run, function () { return api.postWithToken('csrf', confirm, { timeout: 0 }); }); if (uploaded(item, committed, true)) { return; } if (committed && committed.upload && (committed.upload.result === 'Warning' || committed.upload.warnings)) { recordWarning(item, committed.upload); return; } throw unknownResult(); } async function processItem(item, run) { state(item, 'uploading', '准备检查……'); var retries = 0; try { while (true) { try { assertRunning(run); await attemptUpload(item, run); return; } catch (error) { if (error.queueStopped) { state(item, 'queued', '已暂停,等待继续'); return; } if (error.rateLimited) { // 先更新全局退避:即便当前重试额度用尽,其他 worker 也不能绕开冷却。 backoff(error, retries + 1, run); if (!run.stopped && retries < CONFIG.maxRateRetries) { retries++; phase(item, '限流 / 负载等待,第 ' + retries + ' / ' + CONFIG.maxRateRetries + ' 次自动重试……', null); // 下次 attempt 会重新检查目标;如有 filekey,会重查警告,不重新传二进制。 continue; } if (run.stopped) { state(item, 'queued', '已暂停,稍后继续限流重试'); return; } error.stopQueue = true; error.message = '限流 / 负载错误持续,自动重试次数已用完。请降低并发或增加间隔。'; } var unexpected = !error.userInput && !error.code; state(item, 'failed', error.message || String(error), error.detail || '', error.code || (unexpected ? 'client-error' : 'invalid-input')); if (error.stopQueue || unexpected) { stopRun('队列已暂停:' + (error.message || '客户端异常')); } return; } } } finally { if (item.claimedTitle && activeClaims.get(item.claimedTitle) === item.id) { activeClaims.delete(item.claimedTitle); } item.claimedTitle = null; } } async function start() { if (busy || !ready || !counts.queued) { return; } var settings; try { settings = readSettings(); } catch (error) { notice(error.message); return; } // 创建有界 worker 池,不对 6000 个文件直接 Promise.all。 var pending = items.filter(function (item) { return item.state === 'queued'; }); var run = { settings: settings, effectiveGap: settings.gap, stopped: false, stopReason: '', next: 0, controller: new AbortController(), dispatchTail: Promise.resolve(), nextRequestAt: 0 }; session = run; busy = true; notice('正在处理 ' + number(pending.length) + ' 项,并发上限 ' + settings.concurrency + '。' + (settings.duplicates ? '仅 duplicate 内容重复警告会自动确认。' : '所有上传警告均需人工处理。') + '\n不要关闭页面;如触发限流会统一等待,不会通过增加并发绕过。'); flushRender(); runtimeTimer = window.setInterval(scheduleRender, 1000); async function worker() { var item; try { while (!run.stopped && run.next < pending.length) { item = pending[run.next++]; await processItem(item, run); } } catch (error) { if (item && item.state === 'uploading') { state(item, 'failed', '客户端调度异常,请检查目标页面后再重试。', error.message || String(error), 'client-error'); } stopRun('客户端调度异常,已暂停后续请求:' + (error.message || String(error))); } } try { await Promise.all(Array.from({ length: Math.min(settings.concurrency, pending.length) }, function () { return worker(); })); } catch (error) { stopRun('客户端调度异常,请刷新前导出结果并检查目标页面:' + (error.message || String(error))); } finally { // 即便 worker 出现未预料的异常,也先等待仍在途的任务,见 worker 内保护。 busy = false; if (runtimeTimer !== null) { window.clearInterval(runtimeTimer); runtimeTimer = null; } activeClaims.clear(); notice(run.stopped ? run.stopReason + '\n本轮在途请求已返回。待处理项可继续;失败项需排查后重新排队。' : '本轮处理结束。成功项不会再次上传;“跳过”只表示同名已存在,不保证其内容与本地一致。可筛选警告/失败项或导出 CSV。'); session = null; flushRender(); } } function csvCell(value) { var text = String(value === undefined || value === null ? '' : value); // 防止文件名等在 Excel/其他表格软件中被解释成公式。 if (/^[\s]*[=+\-@]/.test(text) || /^[\t\r\n]/.test(text)) { text = "'" + text; } return '"' + text.replace(/"/g, '""') + '"'; } function exportResults() { if (!items.length) { return; } var columns = ['队列ID', '本地文件名', '目标文件名', '大小(字节)', '本地修改时间', '状态', '提示', '错误码', '已自动确认重复', '目标页面']; var lines = [columns.map(csvCell).join(',')]; items.forEach(function (item) { lines.push([item.id, item.file.name, item.target, item.file.size, item.file.lastModified ? new Date(item.file.lastModified).toISOString() : '', labels[item.state], item.message, item.code, item.autoConfirmed ? '是' : '否', new URL(fileUrl(item.target), window.location.href).href].map(csvCell).join(',')); }); var blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; // ASCII 下载文件名兼容部分浏览器;CSV 表头与内容仍是 UTF-8 中文。 a.download = 'MediaWiki-upload-results-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv'; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(function () { URL.revokeObjectURL(url); }, 60000); } 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.target = ui.prefix.value + item.file.name; resetItem(item, true); } }); notice('已按“前缀 + 原始文件名”更新未成功项,并将它们重新排队。请确认后开始。'); flushRender(); }); ui.start.addEventListener('click', function () { start(); }); ui.pause.addEventListener('click', function () { stopRun('已按你的操作暂停队列。'); }); ui.retry.addEventListener('click', function () { if (busy || !ready) { return; } items.forEach(function (item) { if (item.state === 'failed') { resetItem(item, true); } }); notice('失败项已重新排队;未重新排队成功项、同名跳过项和其他警告项。检查原因后点击“开始 / 继续上传”。'); flushRender(); }); ui.export.addEventListener('click', exportResults); ui.clear.addEventListener('click', function () { if (busy) { return; } items = []; Object.keys(counts).forEach(function (key) { counts[key] = 0; }); pageNumber = 1; queueVersion++; notice('已清空本地队列,不会删除已上传到本站的文件。'); flushRender(); }); ui.search.addEventListener('input', function () { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(function () { pageNumber = 1; flushRender(); }, 150); }); ui.filter.addEventListener('change', function () { pageNumber = 1; flushRender(); }); ui.prev.addEventListener('click', function () { pageNumber--; flushRender(); }); ui.next.addEventListener('click', function () { pageNumber++; flushRender(); }); window.addEventListener('beforeunload', function (event) { if (busy || counts.queued || counts.failed || counts.warning) { event.preventDefault(); event.returnValue = ''; } }); flushRender(); if (!window.File || !window.FormData || !window.Promise || !window.AbortController) { notice('浏览器不支持所需功能,请使用较新的 Chrome、Firefox、Edge 或 Safari。'); return; } if (!userName) { notice('请先登录。此工具不支持匿名上传。'); 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 }, ajax: { headers: { 'Api-User-Agent': 'MediaWikiBatchUpload/2.0.0 (' + new URL(mw.util.getUrl(mw.config.get('wgPageName')), window.location.href).href + ')' } } }); 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.includes('upload')) { notice('当前账号没有 upload 权限。站点脚本不能授予上传权限。'); return; } ready = true; notice('当前账号:' + userName + '。内容重复默认自动确认,并发默认 ' + CONFIG.defaultConcurrency + ' 路。建议先用 10~20 个小文件测试,再添加大批量文件。'); flushRender(); }).catch(function (error) { notice('初始化失败:' + error.message + '。请检查登录状态及 API,再刷新。'); }); } }());
返回
MediaWiki:BatchUpload.js
。
反馈问题
导航菜单
导航
首页
最近更改
随机页面
MediaWiki帮助
提交支持工单
您的支持请求已受理。技术支持团队会尽快与您联系。
搜索
报告 Wiki 中的错误
请
不要
使用此表单申诉封号或封锁处罚。此表单仅用于反馈
与 Wiki 相关
的错误或改进建议。
您的游戏昵称
页面链接
我们已为您自动填写此项,如有误,请从浏览器地址栏复制所需页面的链接并粘贴至此处。
留言内容
请告诉我们您希望修改该页面的哪些内容,或指出其中的错误/不准确之处。可附带截图链接。