MediaWiki:BatchUpload.js:修订间差异

来自3D坦克-WIKI

无编辑摘要
无编辑摘要
第1行: 第1行:
/*
/*
  * MediaWiki 批量上传页面 v1.0
  * MediaWiki 批量上传页面 v2.0.0
  * 将本文件全文保存到 MediaWiki:BatchUpload.js(不要加 <script> 标签)。
  * 保存到 MediaWiki:BatchUpload.js;不要加 <script> 标签。
  * 入口:Project:批量上传;加载片段见 Common.js.snippet.js。
  * 保持原入口 Project:批量上传、容器 #mw-batch-upload 不变。
  * 依赖:MediaWiki 自带 mediawiki.api / mediawiki.util / jQuery;现代浏览器。
  * 10,000 项队列 / 有限并发 / 分页 / duplicate 自动确认 / 限流退避 / CSV 导出。
  * 不使用外部服务、不保存密码、不请求忽略上传警告。
  * 仅使用本站 API 和当前账号;不保存密码,不将文件发送到第三方。
  */
  */
(function () {
(function () {
第13行: 第13行:
         pageTitle: '批量上传',
         pageTitle: '批量上传',
         containerId: 'mw-batch-upload',
         containerId: 'mw-batch-upload',
         maxFiles: 250,       // 本地队列上限,不是服务器限制
         maxFiles: 10000,
         intervalMs: 500    // 两个文件之间的间隔;上传始终串行
        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 ||
     if (mw.config.get('wgNamespaceNumber') !== CONFIG.namespace ||
         mw.config.get('wgTitle') !== CONFIG.pageTitle ||
         mw.config.get('wgTitle') !== CONFIG.pageTitle ||
         mw.config.get('wgAction') !== 'view' ||
         mw.config.get('wgAction') !== 'view' || mw.config.get('wgDiffNewId')) {
        mw.config.get('wgDiffNewId')) {
         return;
         return;
     }
     }
第27行: 第35行:
         jQuery(function () {
         jQuery(function () {
             var root = document.getElementById(CONFIG.containerId);
             var root = document.getElementById(CONFIG.containerId);
             if (!root || root.dataset.batchUploadReady) {
             if (!root || root.dataset.batchUploadReady) { return; }
                return;
             root.dataset.batchUploadReady = '2.0.0';
            }
             root.dataset.batchUploadReady = '1';
             mount(root);
             mount(root);
         });
         });
第36行: 第42行:
         jQuery(function () {
         jQuery(function () {
             var root = document.getElementById(CONFIG.containerId);
             var root = document.getElementById(CONFIG.containerId);
             if (root) {
             if (root) { root.textContent = '无法加载 MediaWiki API 模块,请强制刷新或联系界面管理员。'; }
                root.textContent = '无法加载 MediaWiki API 模块。请强制刷新,或联系界面管理员。';
            }
         });
         });
     });
     });
第44行: 第48行:
     function mount(root) {
     function mount(root) {
         mw.util.addCSS([
         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{--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 *{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-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:21px;font-weight:700;margin:0 0 6px}',
             '#mw-batch-upload .bu-heading{font-size:22px;font-weight:700;margin:0 0 4px}',
             '#mw-batch-upload .bu-note{color:var(--color-subtle,#54595d);margin:6px 0 12px;font-size:13px}',
             '#mw-batch-upload .bu-version{font-size:12px;font-weight:400;margin-left:8px;color:var(--bu-muted)}',
             '#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-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 .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 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 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:100px;resize:vertical}',
             '#mw-batch-upload textarea{min-height:96px;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-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:14px;margin:14px 0}',
             '#mw-batch-upload .bu-inline input{flex:1;min-width:140px}',
            '#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 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 .bu-inline{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0 12px}',
             '#mw-batch-upload button.bu-primary{background:#36c;border-color:#36c;color:#fff}',
             '#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 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 input:disabled,#mw-batch-upload textarea:disabled,#mw-batch-upload select:disabled{opacity:.7}',
             '#mw-batch-upload .bu-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}',
             '#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-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-toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}',
             '#mw-batch-upload .bu-scroll{overflow-x:auto}',
             '#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 table{width:100%;min-width:630px;border-collapse:collapse;margin-top:12px}',
             '#mw-batch-upload .bu-summary{font-weight:600;overflow-wrap:anywhere}',
             '#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 .bu-runtime{font-size:13px;min-height:1.6em;color:var(--bu-muted)}',
             '#mw-batch-upload th{font-weight:600;background:var(--background-color-interactive-subtle,#f8f9fa)}',
            '#mw-batch-upload progress{display:block;width:100%;height:8px;accent-color:#36c;margin:8px 0}',
             '#mw-batch-upload .bu-original{width:23%;overflow-wrap:anywhere}',
            '#mw-batch-upload .bu-filter{display:flex;flex-wrap:wrap;align-items:end;gap:10px;margin:18px 0 8px}',
            '#mw-batch-upload .bu-target{width:31%}',
            '#mw-batch-upload .bu-filter label:first-child{flex:2;min-width:160px}',
            '#mw-batch-upload .bu-result{width:36%;overflow-wrap:anywhere}',
            '#mw-batch-upload .bu-filter label:nth-child(2){flex:1;min-width:135px}',
             '#mw-batch-upload .bu-size{font-size:12px;color:var(--color-subtle,#54595d)}',
            '#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 .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=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=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=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 tr[data-state=skipped] .bu-status{color:var(--bu-muted)}',
             '#mw-batch-upload .bu-empty{text-align:center;color:var(--color-subtle,#54595d);padding:24px}',
            '#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}',
             '#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}}'
            '@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'));
         ].join('\n'));


         // HTML 全部为静态字符串。文件名、API 错误、用户输入只以 textContent/value 写入。
         // 这里只插入固定界面;文件名、输入、API 信息一律通过 textContent/value 写入。
         root.innerHTML = `
         root.innerHTML = `
             <section class="bu-panel" aria-label="批量上传文件">
             <section class="bu-panel" aria-label="批量上传文件">
                 <div class="bu-heading">批量上传文件</div>
                 <div class="bu-heading">批量上传文件 <span class="bu-version"></span></div>
                 <p class="bu-note">上传到当前 Wiki,使用当前账号的权限。遇到警告不自动确认,不主动覆盖已有文件。</p>
                 <p class="bu-note">内容重复可自动确认,已有同名文件会跳过。大批量任务建议使用电脑并保持此页打开。</p>
                 <div class="bu-message" data-el="message" role="status" aria-live="polite">正在检查登录状态和上传权限……</div>
                 <div data-el="message" class="bu-message" role="status" aria-live="polite">正在检查登录状态和上传权限……</div>
                 <div class="bu-drop" data-el="drop">
                 <div data-el="drop" class="bu-drop">
                     <button type="button" data-el="choose" disabled>选择多个文件</button>
                     <button type="button" data-el="choose" disabled>选择多个文件</button> 或将文件拖到这里
                    <span> 或将文件拖到这里</span>
                     <input type="file" multiple hidden data-el="files" aria-label="选择待上传文件" disabled>
                     <input type="file" multiple hidden data-el="files" aria-label="选择待上传文件" disabled>
                     <div class="bu-note">可以分多次添加。类型和大小由本站设置决定;本工具不支持目录或分片上传。</div>
                     <div class="bu-note" data-el="queueLimit"></div>
                 </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">
                 <div class="bu-fields">
                     <label>统一文件说明(维基语法)
                     <label>统一文件说明(维基语法)<textarea data-el="description" placeholder="填写真实来源、作者和授权说明。许可模板需使用本站已有且确实适用的模板。" disabled></textarea></label>
                        <textarea data-el="description" placeholder="填写来源、作者和授权说明。若使用许可模板,请填写本站已有、且确实适用的模板。" disabled></textarea>
                     <label>分类(可选,每行一个名称)<textarea data-el="categories" placeholder="活动照片&#10;2026年资料" disabled></textarea></label>
                    </label>
                     <label>分类(可选,每行一个名称)
                        <textarea data-el="categories" placeholder="活动照片&#10;2026年资料" disabled></textarea>
                    </label>
                 </div>
                 </div>
                 <label for="bu-prefix">文件名前缀(可选)</label>
                 <label for="bu-prefix">文件名前缀(可选)</label>
                 <div class="bu-inline">
                 <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>
                    <input id="bu-prefix" data-el="prefix" type="text" placeholder="例如:项目A-" disabled>
                 <p class="bu-note">应用前缀会用“前缀 + 原始文件名”替换未成功项的手工改名,并将它们重新排队;不会叠加前缀。</p>
                    <button type="button" data-el="applyPrefix" disabled>应用到未成功项</button>
                </div>
                 <p class="bu-note">应用前缀会按“前缀 + 原始文件名”重建未成功项的名称,替换手工改名;不会重复叠加前缀。请保留正确的扩展名。</p>
                 <div class="bu-toolbar">
                 <div class="bu-toolbar">
                     <button type="button" class="bu-primary" data-el="start" disabled>开始上传</button>
                     <button type="button" data-el="start" class="bu-primary" disabled>开始 / 继续上传</button>
                     <button type="button" data-el="pause" disabled>本项完成后暂停</button>
                    <button type="button" data-el="pause" disabled>暂停新请求</button>
                     <button type="button" data-el="retry" 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>
                     <button type="button" data-el="clear" disabled>清空列表</button>
                 </div>
                 </div>
                 <div data-el="summary" role="status" aria-live="polite">尚未选择文件</div>
                 <div data-el="summary" class="bu-summary" role="status" aria-live="polite">尚未选择文件</div>
                 <progress data-el="overall" max="1" value="0" aria-label="处理进度(按文件数量)"></progress>
                <div data-el="runtime" class="bu-runtime"></div>
                 <div class="bu-scroll">
                 <progress data-el="overall" max="1" value="0" aria-label="已处理文件数量"></progress>
                     <table>
                 <div class="bu-filter">
                        <thead><tr><th scope="col">本地文件</th><th scope="col">目标文件名</th><th scope="col">状态 / 传输进度</th><th scope="col">操作</th></tr></thead>
                     <label>筛选文件名<input type="text" data-el="search" placeholder="本地名称或目标名称"></label>
                        <tbody data-el="rows"></tbody>
                    <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>
                    </table>
                    <div class="bu-empty" data-el="empty">选择文件后,会在这里显示上传队列。</div>
                 </div>
                 </div>
                 <p class="bu-note">进度条表示传输或已处理数量,不代表上传成功;以“成功”状态为准。暂停不会中断正在处理的文件。清空列表不会删除站内文件。</p>
                <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>`;
             </section>`;


         var ui = {};
         var ui = {};
         root.querySelectorAll('[data-el]').forEach(function (el) { ui[el.dataset.el] = el; });
         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 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 busy = false;
         var pauseRequested = false;
         var session = null;
         var ready = false;
         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 userName = mw.config.get('wgUserName');
         var api;
         var namespaces = mw.config.get('wgFormattedNamespaces') || {};
        var fileNamespace = (mw.config.get('wgFormattedNamespaces') || {})[6] || 'File';
        var fileNamespace = namespaces[6] || 'File';
         var categoryNamespace = (mw.config.get('wgFormattedNamespaces') || {})[14] || 'Category';
         var categoryNamespace = namespaces[14] || 'Category';
        var number = function (v) { return v.toLocaleString(); };


         function message(text) { ui.message.textContent = text; }
         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 fileUrl(name) { return mw.util.getUrl(fileNamespace + ':' + name); }
        function sleep(ms) { return new Promise(function (resolve) { window.setTimeout(resolve, ms); }); }
         function sizeText(bytes) {
         function sizeText(bytes) {
             if (bytes < 1024) { return bytes + ' B'; }
             if (bytes < 1024) { return bytes + ' B'; }
             if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KiB'; }
             if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KiB'; }
             return (bytes / 1048576).toFixed(1) + ' MiB';
             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 counts() {
         function resetItem(item, discardStash) {
             var result = { queued: 0, uploading: 0, success: 0, warning: 0, failed: 0 };
             if (item.state === 'success') { return; }
             items.forEach(function (item) { result[item.state]++; });
             if (discardStash) { item.stashKey = null; }
             return result;
            item.autoConfirmed = false;
             state(item, 'queued', '等待上传');
         }
         }
         function refresh() {
         function getView() {
             var c = counts();
             var term = ui.search.value.trim().toLowerCase();
             var locked = busy || !ready;
             var filter = ui.filter.value;
             ['choose', 'files', 'description', 'categories', 'prefix'].forEach(function (key) {
            var key = term + '\u0000' + filter;
                 ui[key].disabled = locked;
            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();
             });
             });
             ui.applyPrefix.disabled = locked || !items.some(function (item) { return item.state !== 'success'; });
             refs.remove.addEventListener('click', function () {
            ui.start.disabled = locked || !c.queued;
                if (busy) { return; }
            ui.pause.disabled = !busy || pauseRequested;
                counts[item.state]--;
            ui.retry.disabled = locked || !(c.failed + c.warning);
                items = items.filter(function (other) { return other.id !== item.id; });
            ui.clear.disabled = busy || !items.length;
                queueVersion++;
            ui.empty.hidden = items.length > 0;
                 flushRender();
            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;
             });
             });
            return refs;
         }
         }
         function setState(item, state, text) {
         function updateRow(refs, item) {
             item.state = state;
             refs.row.dataset.state = item.state;
             item.row.dataset.state = state;
             if (document.activeElement !== refs.input || busy) { refs.input.value = item.target; }
             item.status.textContent = text;
            refs.input.disabled = busy || !ready || item.state === 'success';
             item.progress.hidden = state !== 'uploading' && state !== 'success';
             setText(refs.status, item.message);
             if (state === 'queued') { item.progress.value = 0; }
             refs.progress.hidden = item.state !== 'uploading' && item.state !== 'success';
             if (state === 'success') { item.progress.value = 100; }
             if (item.progress === null) { refs.progress.removeAttribute('value'); }
             item.link.hidden = state !== 'success' && state !== 'warning' && state !== 'failed';
             else { refs.progress.value = item.progress; }
             item.link.href = fileUrl(item.input.value.trim());
             refs.details.hidden = !item.detail;
             item.link.textContent = state === 'success' ? '查看文件' : '检查目标页面';
            setText(refs.pre, item.detail);
             refresh();
            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 resetItem(item) {
         function render() {
             if (item.state !== 'success') { setState(item, 'queued', '等待上传'); }
            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(files) {
 
         function addFiles(list) {
             if (busy || !ready) { return; }
             if (busy || !ready) { return; }
             var added = 0;
             var added = 0;
             var excess = 0;
             var excess = 0;
             Array.from(files).forEach(function (file) {
             Array.from(list || []).forEach(function (file) {
                if (!(file instanceof window.File)) { return; }
                 if (items.length >= CONFIG.maxFiles) { excess++; return; }
                 if (items.length >= CONFIG.maxFiles) { excess++; return; }
                 var tr = document.createElement('tr');
                 items.push({
                tr.dataset.state = 'queued';
                    id: ++serial, file: file, target: ui.prefix.value + file.name,
                tr.innerHTML = '<td class="bu-original"><div class="bu-name"></div><div class="bu-size"></div></td>' +
                     state: 'queued', message: '等待上传', detail: '', code: '', progress: 0,
                    '<td class="bu-target"><input type="text"></td>' +
                     stashKey: null, autoConfirmed: false
                    '<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);
                 counts.queued++; added++;
                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)); });
             });
             });
        }
             queueVersion++;
        function warningError(text) {
             notice('已添加 ' + number(added) + ' 个文件。检查文件名、来源与授权说明后再开始。' +
             var error = new Error(text);
                 (excess ? '\n另有 ' + number(excess) + ' 个未添加:队列上限为 ' + number(CONFIG.maxFiles) + ' 项。' : '') +
             error.isWarning = true;
                '\n仅将 File 引用加入队列,不会提前把全部文件读成内存数组或生成缩略图。');
            return error;
             flushRender();
        }
        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() {
         function pageText() {
第276行: 第361行:
                 if (!name) { return; }
                 if (!name) { return; }
                 [categoryNamespace, 'Category', '分类', '分類'].forEach(function (ns) {
                 [categoryNamespace, 'Category', '分类', '分類'].forEach(function (ns) {
                     if (name.slice(0, ns.length + 1).toLowerCase() === (ns + ':').toLowerCase()) {
                     if (name.slice(0, ns.length + 1).toLowerCase() === (ns + ':').toLowerCase()) { name = name.slice(ns.length + 1).trim(); }
                        name = name.slice(ns.length + 1).trim();
                    }
                 });
                 });
                 if (!name || /[\[\]{}|<>#\u0000-\u001f\u007f]/.test(name)) {
                 if (!name || /[\[\]{}|<>#\u0000-\u001f\u007f]/.test(name)) {
                     throw new Error('分类请每行只填一个名称,不要填写 [[Category:…]] 等维基代码。');
                     throw new Error('分类请每行只填一个名称,不要填写 [[Category:…]] 等维基代码。');
                 }
                 }
                 if (categories.indexOf(name) === -1) { categories.push(name); }
                 if (!categories.includes(name)) { categories.push(name); }
             });
             });
             var text = ui.description.value.trim();
             var text = ui.description.value.trim();
             if (categories.length) {
             if (categories.length) {
                 text += (text ? '\n\n' : '') + categories.map(function (name) {
                 text += (text ? '\n\n' : '') + categories.map(function (name) { return '[[' + categoryNamespace + ':' + name + ']]'; }).join('\n');
                    return '[[' + categoryNamespace + ':' + name + ']]';
                }).join('\n');
             }
             }
             return text;
             return text;
         }
         }
         async function uploadOne(item, text) {
        function readSettings() {
             var name = item.input.value.trim();
            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)) {
             if (!name || /[\\/:#<>\[\]|{}\u0000-\u001f\u007f]/.test(name) || !/\.[^.\s]+$/.test(name)) {
                 throw new Error('目标名称需包含扩展名,且不能包含路径、命名空间或非法字符(如 / : # [ ])。');
                 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('服务器未返回有效的文件页面信息,请检查目标文件名。');
             }
             }
             if (!item.file.size) {
            item.target = page.title.slice(page.title.indexOf(':') + 1);
                 throw new Error('文件为空,或浏览器无法读取。请使用文件选择器选择普通文件,不要拖入文件夹。');
            queueVersion++;
             if (!page.missing || (page.imageinfo && page.imageinfo.length) || (page.imagerepository && page.imagerepository !== '')) {
                 state(item, 'skipped', '已有同名文件 / 说明页,跳过;未核对其内容。',
                    '也可能是共享仓库文件。本工具不会主动替换该目标。若要保留另一个文件,请修改目标名称后重新排队。');
                return false;
             }
             }
             item.input.value = name;
             var owner = activeClaims.get(page.title);
             setState(item, 'uploading', '正在检查目标文件名……');
             if (owner !== undefined && owner !== item.id) {
            item.progress.removeAttribute('value');
                 state(item, 'warning', '队列内另一项正在处理同一个规范化文件名,未上传。', '请使用不同的目标名称,或等待另一项完成后检查目标页面。', 'queue-name-conflict');
 
                 return false;
            // 同时检查本地文件说明页及共享仓库文件;避免无意遮蔽共享文件。
            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) ||
             activeClaims.set(page.title, item.id);
                 (page.imagerepository && page.imagerepository !== '')) {
            item.claimedTitle = page.title;
                 throw warningError('目标文件或文件说明页已存在(可能来自共享仓库)。未发送文件;请改名或使用已有文件。');
            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;
             name = page.title.slice(page.title.indexOf(':') + 1);
        }
             item.input.value = name;
        function unknownResult() {
            item.progress.value = 0;
             var error = new Error('服务器返回未识别的上传结果。请先检查目标页面,不要直接重复上传。');
             item.status.textContent = '正在获取令牌并上传……';
             error.stopQueue = true; error.code = 'unknown-upload-result';
 
             return error;
            var result = await request(api.postWithToken('csrf', {
        }
                action: 'upload', filename: name, file: item.file,
        function uploadParameters(item, run) {
                text: text, comment: '使用批量上传页面上传', watchlist: 'nochange'
            return { action: 'upload', filename: item.target, text: run.settings.text,
                // 刻意不传 ignorewarnings,也不根据 filekey 自动确认警告。
                comment: '使用批量上传页面上传', watchlist: 'nochange' };
             }, {
        }
                 contentType: 'multipart/form-data', // mw.Api 会构造 FormData 并设置 boundary
        function binaryOptions(item) {
                timeout: 0,
             return {
                 contentType: 'multipart/form-data', timeout: 0,
                 xhr: function () {
                 xhr: function () {
                     var xhr = jQuery.ajaxSettings.xhr();
                     var xhr = jQuery.ajaxSettings.xhr();
                     if (xhr.upload) {
                     if (xhr.upload) {
                         xhr.upload.addEventListener('progress', function (event) {
                         xhr.upload.addEventListener('progress', function (event) {
                             if (event.lengthComputable) {
                             if (event.lengthComputable && item.state === 'uploading') {
                                 var percent = Math.min(100, Math.round(event.loaded / event.total * 100));
                                 var percent = Math.min(100, Math.round(event.loaded / event.total * 100));
                                 item.progress.value = percent;
                                 phase(item, percent < 100 ? '传输中 ' + percent + '%' : '传输完成,等待服务器确认……', percent);
                                item.status.textContent = percent < 100 ? '正在传输 ' + percent + '%' : '传输完成,等待服务器确认……';
                             }
                             }
                         });
                         });
第344行: 第548行:
                     return xhr;
                     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;
             var upload = result && result.upload;
            // 只以 API 的 Success 为成功;HTTP 200 或传输到 100% 都不是成功确认。
             if (!upload || (upload.result !== 'Warning' && !upload.warnings)) { throw unknownResult(); }
             if (upload && upload.result === 'Success') {
            if (typeof upload.filekey === 'string' && upload.filekey) { item.stashKey = upload.filekey; }
                item.input.value = upload.filename || name;
            var keys = warningKeys(upload);
                 setState(item, 'success', '成功');
            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;
                 return;
             }
             }
             if (upload && (upload.result === 'Warning' || upload.warnings)) {
            // 自动处理 duplicate,而不是对首次请求无条件设置 ignorewarnings=1。
                 throw warningError(warningText(upload.warnings) + '\n本工具没有确认警告,未完成正式发布。');
            // 再查同名目标,并用本页互斥表防止多个 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;
             }
             }
             var unknown = new Error('服务器返回了未识别的上传结果。请先检查目标文件页面,不要直接重复上传。');
             throw unknownResult();
            unknown.stopQueue = true;
            throw unknown;
         }
         }
         async function run() {
         async function processItem(item, run) {
             if (busy || !ready || !items.some(function (item) { return item.state === 'queued'; })) { return; }
             state(item, 'uploading', '准备检查……');
             var text;
             var retries = 0;
            try { text = pageText(); }
            catch (error) { message(error.message); return; }
            busy = true;
            pauseRequested = false;
            refresh();
            message('正在逐个上传。请保持页面打开;“本项完成后暂停”不会中断已发出的请求。');
             try {
             try {
                 for (var i = 0; i < items.length; i++) {
                 while (true) {
                    if (pauseRequested) { break; }
                    var item = items[i];
                    if (item.state !== 'queued') { continue; }
                     try {
                     try {
                         await uploadOne(item, text);
                        assertRunning(run);
                         await attemptUpload(item, run);
                        return;
                     } catch (error) {
                     } catch (error) {
                         setState(item, error.isWarning ? 'warning' : 'failed',
                         if (error.queueStopped) { state(item, 'queued', '已暂停,等待继续'); return; }
                             (error.message || String(error)) + (error.code ? ' [' + error.code + ']' : ''));
                        if (error.rateLimited) {
                         if (error.stopQueue) { pauseRequested = true; }
                            // 先更新全局退避:即便当前重试额度用尽,其他 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);
                     }
                     }
                     if (!pauseRequested && i < items.length - 1) { await sleep(CONFIG.intervalMs); }
                } 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 {
             } finally {
                // 即便 worker 出现未预料的异常,也先等待仍在途的任务,见 worker 内保护。
                 busy = false;
                 busy = false;
                 refresh();
                 if (runtimeTimer !== null) { window.clearInterval(runtimeTimer); runtimeTimer = null; }
                 message(pauseRequested ?
                 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);
         }
         }


第394行: 第696行:
         ui.files.addEventListener('change', function () { addFiles(ui.files.files); ui.files.value = ''; });
         ui.files.addEventListener('change', function () { addFiles(ui.files.files); ui.files.value = ''; });
         ['dragenter', 'dragover'].forEach(function (type) {
         ['dragenter', 'dragover'].forEach(function (type) {
             ui.drop.addEventListener(type, function (event) {
             ui.drop.addEventListener(type, function (event) { event.preventDefault(); if (ready && !busy) { ui.drop.classList.add('bu-over'); } });
                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('dragleave', function (event) { if (!ui.drop.contains(event.relatedTarget)) { ui.drop.classList.remove('bu-over'); } });
         ui.drop.addEventListener('drop', function (event) {
         ui.drop.addEventListener('drop', function (event) {
             event.preventDefault();
             event.preventDefault(); ui.drop.classList.remove('bu-over');
            ui.drop.classList.remove('bu-over');
             if (event.dataTransfer) { addFiles(event.dataTransfer.files); }
             if (event.dataTransfer) { addFiles(event.dataTransfer.files); }
         });
         });
         ui.applyPrefix.addEventListener('click', function () {
         ui.applyPrefix.addEventListener('click', function () {
             if (busy || !ready) { return; }
             if (busy || !ready) { return; }
             items.forEach(function (item) {
             items.forEach(function (item) { if (item.state !== 'success') { item.target = ui.prefix.value + item.file.name; resetItem(item, true); } });
                if (item.state !== 'success') { item.input.value = ui.prefix.value + item.file.name; resetItem(item); }
             notice('已按“前缀 + 原始文件名”更新未成功项,并将它们重新排队。请确认后开始。'); flushRender();
            });
             message('已按“前缀 + 原始文件名”更新未成功项。请检查后再开始上传。');
        });
        ui.start.addEventListener('click', function () { run(); });
        ui.pause.addEventListener('click', function () {
            pauseRequested = true;
            message('将在当前文件处理完成后暂停;当前请求不会中止。');
            refresh();
         });
         });
        ui.start.addEventListener('click', function () { start(); });
        ui.pause.addEventListener('click', function () { stopRun('已按你的操作暂停队列。'); });
         ui.retry.addEventListener('click', function () {
         ui.retry.addEventListener('click', function () {
             if (busy || !ready) { return; }
             if (busy || !ready) { return; }
             items.forEach(function (item) {
             items.forEach(function (item) { if (item.state === 'failed') { resetItem(item, true); } });
                if (item.state === 'failed' || item.state === 'warning') { resetItem(item); }
             notice('失败项已重新排队;未重新排队成功项、同名跳过项和其他警告项。检查原因后点击“开始 / 继续上传”。'); flushRender();
            });
             run();
         });
         });
        ui.export.addEventListener('click', exportResults);
         ui.clear.addEventListener('click', function () {
         ui.clear.addEventListener('click', function () {
             if (busy) { return; }
             if (busy) { return; }
             items = [];
             items = []; Object.keys(counts).forEach(function (key) { counts[key] = 0; });
             ui.rows.textContent = '';
             pageNumber = 1; queueVersion++;
             message('已清空本地列表。此前上传到本站的文件不会被删除。');
            notice('已清空本地队列,不会删除已上传到本站的文件。'); flushRender();
             refresh();
        });
        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) {
         window.addEventListener('beforeunload', function (event) {
             if (busy || items.some(function (item) { return item.state !== 'success'; })) {
             if (busy || counts.queued || counts.failed || counts.warning) { event.preventDefault(); event.returnValue = ''; }
                event.preventDefault();
                event.returnValue = ''; // 浏览器显示自己的离开页面提示文字
            }
         });
         });
 
        flushRender();
         if (!window.File || !window.FormData || !window.Promise) {
         if (!window.File || !window.FormData || !window.Promise || !window.AbortController) {
             message('浏览器不支持所需的文件上传功能,请使用较新的 Chrome、Firefox、Edge 或 Safari。');
             notice('浏览器不支持所需功能,请使用较新的 Chrome、Firefox、Edge 或 Safari。'); return;
            return;
         }
         }
         if (!userName) {
         if (!userName) {
             message('请先登录,再打开此页面。本工具不提供匿名上传。');
             notice('请先登录。此工具不支持匿名上传。');
             var login = document.createElement('a');
             var login = document.createElement('a');
             login.href = mw.util.getUrl('Special:UserLogin', { returnto: mw.config.get('wgPageName').replace(/_/g, ' ') });
             login.href = mw.util.getUrl('Special:UserLogin', { returnto: mw.config.get('wgPageName').replace(/_/g, ' ') });
             login.textContent = ' 前往登录';
             login.textContent = ' 前往登录'; ui.message.appendChild(login); return;
            ui.message.appendChild(login);
            return;
         }
         }
         api = new mw.Api({ parameters: {
         api = new mw.Api({
            formatversion: 2, errorformat: 'plaintext',
            parameters: { formatversion: 2, errorformat: 'plaintext', assert: 'user', assertuser: userName, maxlag: 5 },
            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) {
         request(api.get({ action: 'query', meta: 'userinfo', uiprop: 'rights' })).then(function (data) {
             var info = data.query && data.query.userinfo;
             var info = data.query && data.query.userinfo;
             if (!info || !Array.isArray(info.rights)) {
             if (!info || !Array.isArray(info.rights)) { throw new Error('未能确认账号上传权限,请刷新或联系管理员。'); }
                throw new Error('未能确认账号的上传权限,请刷新或联系管理员。');
             if (!info.rights.includes('upload')) { notice('当前账号没有 upload 权限。站点脚本不能授予上传权限。'); return; }
            }
             if (info.rights.indexOf('upload') === -1) {
                message('当前账号没有 upload 权限。界面脚本不能授予上传权限,请联系站点管理员。');
                return;
            }
             ready = true;
             ready = true;
             message('当前账号:' + userName + '。请选择文件。建议先用 2~3 个小文件测试。');
             notice('当前账号:' + userName + '。内容重复默认自动确认,并发默认 ' + CONFIG.defaultConcurrency + ' 路。建议先用 10~20 个小文件测试,再添加大批量文件。');
             refresh();
             flushRender();
         }).catch(function (error) {
         }).catch(function (error) { notice('初始化失败:' + error.message + '。请检查登录状态及 API,再刷新。'); });
            message('初始化失败:' + error.message + '。请检查登录状态或 API 是否可用,再刷新页面。');
        });
     }
     }
}());
}());

2026年9月11日 (五) 11:45的版本

/*
 * 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="活动照片&#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('分类请每行只填一个名称,不要填写 [[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,再刷新。'); });
    }
}());
报告 Wiki 中的错误
请不要使用此表单申诉封号或封锁处罚。此表单仅用于反馈与 Wiki 相关的错误或改进建议。
您的游戏昵称
页面链接
我们已为您自动填写此项,如有误,请从浏览器地址栏复制所需页面的链接并粘贴至此处。
留言内容
请告诉我们您希望修改该页面的哪些内容,或指出其中的错误/不准确之处。可附带截图链接。