MediaWiki:BatchUpload.js

来自3D坦克-WIKI

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
  • Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* Wiki 批量上传工具 — duplicate-archive 白名单补丁
 * 替换 MediaWiki:BatchUpload.js;基于源站修订 20890,仅改变允许自动确认的警告及对应文案。
 * 自动确认仅限 duplicate / duplicate-archive / exists-normalized,且须启用界面复选框。
 * 第一次请求不忽略警告;确认时必须有 filekey,并再次检查目标不存在。
 * 完全同名文件、说明页及共享文件仍会跳过;其他警告仍需人工处理。
 * 不恢复或清理删除存档,不绕过上传权限,不向外站发送文件。
 */
(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 = '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);--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-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">批量上传文件</div>
                <p class="bu-note">内容重复(duplicate)、与已删除存档内容重复(duplicate-archive)、同名不同扩展名(exists-normalized)可自动确认;已有完全同名文件会跳过。大批量任务建议使用电脑并保持此页打开。</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、duplicate-archive 与 exists-normalized 警告
<span class="bu-note">duplicate-archive:上传内容与曾被删除并进入存档的文件内容相同。允许以当前目标名重新上传,不会恢复或清理原删除存档;不应据此重新上传因侵权、隐私等原因被删除的内容。exists-normalized:站内已有同名但扩展名不同的文件(如已有 X.jpg 时上传 X.png)。完全同名文件仍会跳过;不修改已有文件说明。目标名称本身曾被删除的 was-deleted 警告仍需人工处理。</span></span></label>
                <div class="bu-fields">
                    <label>统一文件说明(维基语法)<textarea data-el="description" placeholder="填写真实来源、作者和授权说明。许可模板需使用本站已有且确实适用的模板。" disabled></textarea></label>
                    <label>分类(可选,每行一个名称)<textarea data-el="categories" placeholder="活动照片&#10;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('分类请每行只填一个名称。');
                }
                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);
            // 只自动确认以下内容重复 / 近似名称警告;必须整组警告都在白名单内。
            // duplicate-archive:内容与已删除存档中的文件相同,不代表当前目标名已存在。
            // 其余警告(完全同名 exists、目标曾删除 was-deleted、非法文件名等)仍需人工处理。
            var autoConfirmable = keys.length > 0 && keys.every(function (key) {
                return key === 'duplicate' || key === 'duplicate-archive' || key === 'exists-normalized';
            });
            if (!run.settings.duplicates || !autoConfirmable) { recordWarning(item, upload); return; }
            if (!item.stashKey) {
                recordWarning(item, upload, '存在可自动确认的警告,但服务器未返回 filekey,无法安全自动确认。');
                return;
            }
            // 自动确认前再查一次目标,并用本页互斥表防止多个 worker 同时发布同一名称。
            // 跨客户端竞态仍需服务器 reupload 权限控制;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、duplicate-archive 与 exists-normalized 警告会自动确认;其他警告不自动忽略。' : '所有上传警告均需人工处理。') +
                '\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 (' + 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 + '。duplicate、duplicate-archive 与 exists-normalized 警告默认自动确认,并发默认 ' + CONFIG.defaultConcurrency + ' 路。建议先用 10~20 个小文件测试,再添加大批量文件。');
            flushRender();
        }).catch(function (error) { notice('初始化失败:' + error.message + '。请检查登录状态及 API,再刷新。'); });
    }
}());
报告 Wiki 中的错误
请不要使用此表单申诉封号或封锁处罚。此表单仅用于反馈与 Wiki 相关的错误或改进建议。
您的游戏昵称
页面链接
我们已为您自动填写此项,如有误,请从浏览器地址栏复制所需页面的链接并粘贴至此处。
留言内容
请告诉我们您希望修改该页面的哪些内容,或指出其中的错误/不准确之处。可附带截图链接。