123pan JSON 高级工具集 (拆分与合并)

为123网盘用户设计,一个功能强大的油猴脚本。不仅可以将超大JSON文件拆分,还新增了将多个JSON文件合并为一体的独立功能。完美集成至左侧菜单,适配所有布局。

// ==UserScript==
// @name         123pan JSON 高级工具集 (拆分与合并)
// @namespace    http://tampermonkey.net/
// @version      2.2.2
// @description  为123网盘用户设计,一个功能强大的油猴脚本。不仅可以将超大JSON文件拆分,还新增了将多个JSON文件合并为一体的独立功能。完美集成至左侧菜单,适配所有布局。
// @author       @一只氧气
// @match        *://www.123pan.com/*
// @match        *://*.123pan.com/*
// @match        *://*.123pan.cn/*
// @match        *://*.123865.com/*
// @match        *://*.123684.com/*
// @match        *://*.123912.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=123pan.com
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// ==/UserScript==

(function() {
    'use strict';

    // --- Constants ---
    const SCRIPT_NAME_SPLITTER = "123pan JSON 拆分工具";
    const SCRIPT_NAME_MERGER = "123pan JSON 合并工具";
    const SCRIPT_VERSION = "2.2.2"; // Final icon fix
    const BROWSER_DOWNLOAD_LIMIT = 10;
    const SEQUENTIAL_DOWNLOAD_DELAY = 1500; // ms

    // --- Core Logic (No changes here) ---
    const coreLogic = {
        analyzeJsonStructure: function(jsonData) {
            try {
                if (!jsonData || !Array.isArray(jsonData.files)) { return { error: "JSON数据格式无效,缺少 'files' 数组。" }; }
                const tree = {}; let maxDepth = 0;
                const commonPathPrefix = jsonData.commonPath ? `${jsonData.commonPath.replace(/\/$/, '')}/` : '';
                jsonData.files.forEach(file => {
                    const fullPath = commonPathPrefix + file.path;
                    const parts = fullPath.split('/').filter(p => p);
                    let currentLevel = tree;
                    parts.forEach((part, index) => {
                        if (index === parts.length - 1) { currentLevel[part] = null; }
                        else { if (!currentLevel[part]) { currentLevel[part] = {}; } currentLevel = currentLevel[part]; }
                    });
                    if (parts.length > maxDepth) { maxDepth = parts.length; }
                });
                function formatTree(node, prefix = '', depth = 1) {
                    let result = ''; const keys = Object.keys(node);
                    keys.forEach((key, index) => {
                        const isNodeLast = index === keys.length - 1;
                        const connector = isNodeLast ? '└── ' : '├── ';
                        const itemPrefix = node[key] === null ? '📄 ' : '📁 ';
                        result += `${prefix}${connector}${itemPrefix}${key}  (Lv. ${depth})\n`;
                        if (node[key] !== null) {
                            const newPrefix = prefix + (isNodeLast ? '    ' : '│   ');
                            result += formatTree(node[key], newPrefix, depth + 1);
                        }
                    });
                    return result;
                }
                const treeString = formatTree(tree);
                const fileCount = jsonData.files.length;
                const totalSize = jsonData.files.reduce((sum, file) => sum + (Number(file.size) || 0), 0);
                return { fileCount, totalSize: uiManager.formatBytes(totalSize), maxDepth, treeString };
            } catch (e) { return { error: `解析JSON时出错: ${e.message}` }; }
        },
        splitImportedJsonFile: function(jsonData, originalFilteredData, originalFileName, splitMethod, config) {
            try {
                if (!jsonData || !Array.isArray(jsonData.files)) { uiManager.showError("JSON数据格式无效,缺少 'files' 数组。"); return null; }
                const baseMetadata = { ...jsonData };
                delete baseMetadata.files; delete baseMetadata.totalFilesCount; delete baseMetadata.totalSize; delete baseMetadata.formattedTotalSize;
                const baseFileName = originalFileName.endsWith('.json') ? originalFileName.slice(0, -5) : originalFileName;
                const chunks = [];
                if (splitMethod === 'byCount') {
                    const chunkSize = config.chunkSize;
                    if (!chunkSize || chunkSize <= 0) { uiManager.showError("按数量拆分时,请输入有效的正整数。"); return null; }
                    for (let i = 0; i < jsonData.files.length; i += chunkSize) {
                        const chunkFiles = jsonData.files.slice(i, i + chunkSize);
                        const chunkTotalSize = chunkFiles.reduce((sum, file) => sum + (Number(file.size) || 0), 0);
                        const chunkJsonData = { ...baseMetadata, totalFilesCount: chunkFiles.length, totalSize: chunkTotalSize, formattedTotalSize: uiManager.formatBytes(chunkTotalSize), files: chunkFiles };
                        chunks.push({ data: chunkJsonData, filename: `${baseFileName}_part_${chunks.length + 1}.json` });
                    }
                } else if (splitMethod === 'byFolder') {
                    const level = config.level;
                    if (!level || level <= 0) { uiManager.showError("按目录层级拆分时,请输入有效的正整数。"); return null; }
                    const commonPathPrefixForDepth = jsonData.commonPath ? `${jsonData.commonPath.replace(/\/$/, '')}/` : '';
                    const originalCommonPathForOutput = jsonData.commonPath ? `${jsonData.commonPath.replace(/\/$/, '')}/` : '';
                    const folders = new Map();
                    jsonData.files.forEach(file => {
                        const fullPathForDepth = commonPathPrefixForDepth + file.path;
                        const pathParts = fullPathForDepth.split('/').filter(p => p.trim() !== '');
                        const dirParts = pathParts.slice(0, -1);
                        const groupKey = (dirParts.length >= level) ? dirParts.slice(0, level).join('/') : '_root_';
                        if (!folders.has(groupKey)) { folders.set(groupKey, []); }
                        folders.get(groupKey).push(file);
                    });
                    for (const [groupKey, filesInGroup] of folders.entries()) {
                        const sanitizedGroupKey = groupKey.replace(/[\/:*?"<>|]/g, '_');
                        let newCommonPath, newFilesArray, outputFileName;
                        if (groupKey === '_root_') {
                            newCommonPath = originalCommonPathForOutput;
                            newFilesArray = filesInGroup;
                            outputFileName = `${baseFileName}_${sanitizedGroupKey}.json`;
                        } else {
                            newCommonPath = groupKey + '/';
                            newFilesArray = filesInGroup.map(originalFile => {
                                const fullPathForThisFile = commonPathPrefixForDepth + originalFile.path;
                                const newRelativePath = fullPathForThisFile.substring(groupKey.length).replace(/^\//, '');
                                return { ...originalFile, path: newRelativePath };
                            });
                            outputFileName = `${baseFileName}_${sanitizedGroupKey}.json`;
                        }
                        if (newFilesArray.length === 0) continue;
                        const chunkTotalSize = newFilesArray.reduce((sum, file) => sum + (Number(file.size) || 0), 0);
                        const chunkJsonData = { ...baseMetadata, commonPath: newCommonPath, totalFilesCount: newFilesArray.length, totalSize: chunkTotalSize, formattedTotalSize: uiManager.formatBytes(chunkTotalSize), files: newFilesArray };
                        chunks.push({ data: chunkJsonData, filename: outputFileName });
                    }
                }

                if (chunks.length > 0) {
                     const verificationResult = coreLogic.verifyChunks(jsonData, chunks);
                     if (!verificationResult.success) {
                         const errorMessage = `校验失败!数据可能丢失!\n应有文件数: ${verificationResult.originalTotalFiles}, 拆分后总数: ${verificationResult.newTotalFiles}\n\n操作已取消。`;
                         uiManager.showError(errorMessage.replace(/\n/g, '<br>'), 10000);
                         return null;
                     }
                    if (splitMethod === 'byFolder') {
                        const rootChunk = chunks.find(c => c.filename.includes('_root_.json'));
                        if (rootChunk) { uiManager.showAlert(`提示:有 ${rootChunk.data.files.length} 个文件被打包到了 "_root_.json" 中。`, 4000); }
                    }
                    return { chunks, originalFilteredData, originalFileName, baseFileName };
                } else {
                    uiManager.showError("没有可供拆分的文件。请检查您的过滤设置。");
                    return null;
                }
            } catch (e) { console.error(`[${SCRIPT_NAME_SPLITTER}] 拆分失败:`, e); uiManager.showError(`拆分失败: ${e.message}.`); return null; }
        },
        verifyChunks: function(originalData, chunks) {
            const originalTotalFiles = originalData.files.length;
            const newTotalFiles = chunks.reduce((sum, chunk) => sum + chunk.data.files.length, 0);
            return { success: originalTotalFiles === newTotalFiles, originalTotalFiles, newTotalFiles };
        },
        mergeJsonFiles: function(filesToMerge) {
            try {
                if (filesToMerge.length < 2) {
                    uiManager.showError("请至少选择两个文件进行合并。");
                    return null;
                }
                const baseJson = filesToMerge[0].data;
                const mergedFilesArray = [...baseJson.files];

                for (let i = 1; i < filesToMerge.length; i++) {
                    const nextJson = filesToMerge[i].data;
                    if (nextJson && Array.isArray(nextJson.files)) {
                        mergedFilesArray.push(...nextJson.files);
                    } else {
                         uiManager.showError(`文件 "${filesToMerge[i].name}" 格式无效,已跳过。`);
                    }
                }

                const finalJson = { ...baseJson };
                finalJson.files = mergedFilesArray;
                finalJson.totalFilesCount = mergedFilesArray.length;
                const totalSize = mergedFilesArray.reduce((sum, file) => sum + (Number(file.size) || 0), 0);
                finalJson.totalSize = totalSize;
                finalJson.formattedTotalSize = uiManager.formatBytes(totalSize);

                return finalJson;

            } catch(e) {
                console.error(`[${SCRIPT_NAME_MERGER}] 合并失败:`, e);
                uiManager.showError(`合并失败: ${e.message}.`);
                return null;
            }
        }
    };

    // --- UI Manager ---
    const uiManager = {
        showCompletionSummary: function(originalFileName, originalFilteredData, downloadedChunks) {
            const originalTotalFiles = originalFilteredData.files.length;
            const originalTotalSize = originalFilteredData.files.reduce((sum, file) => sum + (Number(file.size) || 0), 0);
            const listItems = downloadedChunks.map(chunk => `<li><strong>${chunk.filename}</strong>: ${chunk.data.files.length} 个文件 - ${this.formatBytes(chunk.data.totalSize)}</li>`).join('');
            const newTotalFiles = downloadedChunks.reduce((sum, chunk) => sum + chunk.data.files.length, 0);
            const newTotalSize = downloadedChunks.reduce((sum, chunk) => sum + chunk.data.totalSize, 0);
            const summaryHtml = `<div style="text-align: left; line-height: 1.6;"><strong>原始文件 (过滤后):</strong> ${originalFileName}<br>- 总文件数: ${originalTotalFiles}<br>- 总大小: ${this.formatBytes(originalTotalSize)}<hr style="margin: 15px 0;"><strong>已下载文件列表 (${downloadedChunks.length}个):</strong><ul style="padding-left: 20px; margin-top: 10px; max-height: 150px; overflow-y: auto; background: #f7f7f7; border: 1px solid #eee; border-radius: 4px; padding: 10px;">${listItems}</ul><div style="font-weight: bold; margin-top: 10px; text-align: right;">合计: ${newTotalFiles} 个文件 - ${this.formatBytes(newTotalSize)}</div></div>`;
            this.showCenteredInfoModal("✅ 下载已启动!", summaryHtml);
        },
        showCenteredInfoModal: function(title, htmlContent) {
            if (document.getElementById('summary-modal-overlay')) { document.getElementById('summary-modal-overlay').remove(); }
            const overlay = document.createElement('div');
            overlay.id = 'summary-modal-overlay';
            overlay.className = 'splitter-overlay';
            const modalContainer = document.createElement('div');
            modalContainer.className = 'summary-container';
            modalContainer.innerHTML = `<h2 class="summary-title">${title}</h2><div class="summary-content">${htmlContent}</div><button id="summary-ok-btn" class="summary-ok-btn">确定</button>`;
            overlay.appendChild(modalContainer);
            document.body.appendChild(overlay);
            const close = () => overlay.remove();
            modalContainer.querySelector('#summary-ok-btn').onclick = close;
            overlay.onclick = (e) => { if (e.target === overlay) { close(); } };
        },
        showProgressModal: function(message) {
            this.hideProgressModal();
            const overlay = document.createElement('div');
            overlay.id = 'progress-modal-overlay';
            overlay.className = 'splitter-overlay';
            overlay.style.zIndex = '10004';
            overlay.innerHTML = `<div class="progress-container"><div class="progress-spinner"></div><div class="progress-text">${message}</div></div>`;
            document.body.appendChild(overlay);
        },
        hideProgressModal: function() {
            const overlay = document.getElementById('progress-modal-overlay');
            if (overlay) { overlay.remove(); }
        },
        _downloadToFile: function(content, filename, contentType) {
            try {
                const blob = (content instanceof Blob) ? content : new Blob([content], { type: contentType });
                const a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = filename;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(a.href);
            } catch (e) { console.error("下载失败:", e); this.showError(`下载文件 "${filename}" 失败。`); }
        },
        formatBytes: function(bytes, decimals = 2) {
            if (bytes === 0) return '0 Bytes';
            const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
        },
        showAlert: function(message, duration = 3000) {
            const el = document.createElement('div');
            el.style.zIndex = '10005'; el.className = 'splitter-info-popup'; el.innerHTML = message; document.body.appendChild(el);
            setTimeout(() => { el.classList.add('fadeout'); setTimeout(() => el.remove(), 500); }, duration);
        },
        showError: function(message, duration = 4000) { this.showAlert(`<span style="color: #ffcdd2;">⚠️ ${message}</span>`, duration); },
        applyStyles: function() {
            // No custom styles needed for sidebar items, but keeping for modals.
            if (document.getElementById('splitter-tool-styles')) return;
            GM_addStyle(`
                .splitter-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; }
                .splitter-modal-close-btn { position: absolute; top: 10px; right: 15px; font-size: 28px; font-weight: bold; color: #999; cursor: pointer; transition: color 0.3s; z-index: 1; }
                .splitter-container { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; background: #fff; padding: 0; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); width: 90vw; max-width: 700px; text-align: center; position: relative; display: flex; flex-direction: column; max-height: 90vh; }
                .splitter-title { margin: 0; color: #333; padding: 1.5rem 2rem; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; }
                .splitter-body { overflow-y: auto; padding: 1rem 2rem; flex-grow: 1; text-align: left; }
                .splitter-actions { padding: 1.5rem 2rem; border-top: 1px solid #f0f0f0; background: #fafafa; text-align: center; flex-shrink: 0; }
                .splitter-drop-area { border: 2px dashed #d9d9d9; padding: 40px 20px; border-radius: 8px; transition: all .3s; margin-bottom: 1.5rem; text-align: center; }
                .splitter-drop-area.drag-over { border-color: #1890ff; background-color: #e6f7ff; }
                #splitter-file-status, #merger-file-status { color: #52c41a; margin-top: 1rem; font-weight: 500; min-height: 1.2em; text-align: center;}
                #splitter-analysis-result { background-color: #fafafa; border: 1px solid #d9d9d9; border-radius: 4px; padding: 15px; margin-top: 1.5rem; text-align: left; max-height: 250px; overflow-y: auto; white-space: pre; font-family: 'Courier New', Courier, monospace; font-size: 0.85em; line-height: 1.6; }
                .splitter-options, .splitter-filter-options { margin: 1.5rem 0; border: 1px solid #f0f0f0; padding: 1.5rem; border-radius: 8px; text-align: left; }
                .splitter-options > div { margin-bottom: 1rem; }
                .splitter-options > div:last-child { margin-bottom: 0; }
                .button-primary, .download-action-btn, .button-secondary { font-size: 1rem; padding: 10px 15px; min-width: 170px; border: none; border-radius: 4px; cursor: pointer; transition: background-color .3s; margin: 5px; }
                .button-primary { background-color: #52c41a; color: #fff; }
                .button-primary:disabled { background-color: #d9d9d9; cursor: not-allowed; }
                .button-secondary { background-color: #1890ff; color: #fff; }
                .btn-green { background-color: #52c41a; color: white; }
                .btn-blue { background-color: #1890ff; color: white; }
                .btn-grey { background-color: #888; color: white; }
                .link-style-btn { background: none; border: none; color: #1890ff; cursor: pointer; padding: 0; font-size: inherit; text-decoration: underline; }
                .splitter-footer { padding: 1rem 2rem; font-size: 0.8em; color: #999; text-align: center; background: #fafafa; border-top: 1px solid #f0f0f0; flex-shrink: 0;}
                .splitter-info-popup { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0,0,0,0.8); color: white; padding: 12px 22px; border-radius: 5px; opacity: 1; transition: opacity 0.5s ease-out; font-size: 1em; max-width: 90vw; }
                .summary-container { background: #fff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); width: 90vw; max-width: 600px; text-align: center; }
                .summary-ok-btn { font-size: 1.1rem; padding: 10px 40px; background-color: #1890ff; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
                .chunk-list { flex-grow: 1; overflow-y: auto; border: 1px solid #eee; border-radius: 4px; padding: 10px; margin-top: 1rem;}
                .chunk-item { display: flex; align-items: center; padding: 8px; border-bottom: 1px solid #f0f0f0; }
                .chunk-item:last-child { border-bottom: none; }
                .chunk-item input[type="checkbox"] { margin-right: 15px; flex-shrink: 0; }
                .chunk-item label { word-break: break-all; }
                .chunk-item-details { color: #666; font-size: 0.9em; margin-left: auto; white-space: nowrap; padding-left: 10px; }
                .selection-header, .selection-summary, .merger-summary { padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; padding: 1rem; margin-bottom: 1rem; background: #fafafa;}
                .selection-summary, .merger-summary { border-top: 1px solid #f0f0f0; margin-top: 1rem; margin-bottom: 0; font-weight: bold; }
                .progress-container { background: white; color: black; padding: 30px 40px; border-radius: 8px; display: flex; align-items: center; font-size: 1.2em; }
                .progress-spinner { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin-right: 20px; }
                @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
            `);
        },
        showDownloadSelectionView: function(resultData) {
            const { chunks, originalFilteredData, originalFileName, baseFileName } = resultData;
            const modalContainer = document.querySelector('.splitter-container');
            const splitterBody = modalContainer.querySelector('.splitter-body');
            const splitterActions = modalContainer.querySelector('.splitter-actions');
            const splitterTitle = modalContainer.querySelector('.splitter-title');
            const originalBodyHTML = splitterBody.innerHTML;
            const originalActionsHTML = splitterActions.innerHTML;
            const originalTitle = splitterTitle.textContent;
            const originalTotalFiles = originalFilteredData.files.length;
            const originalTotalSize = originalFilteredData.files.reduce((sum, file) => sum + (Number(file.size) || 0), 0);
            const chunkListItems = chunks.map((chunk, index) => `<div class="chunk-item"><input type="checkbox" class="chunk-checkbox" id="chunk-checkbox-${index}" value="${index}" checked><label for="chunk-checkbox-${index}"><strong>${chunk.filename}</strong></label><span class="chunk-item-details">${chunk.data.files.length} 个文件, ${this.formatBytes(chunk.data.totalSize)}</span></div>`).join('');

            splitterTitle.textContent = '拆分完成 - 请选择文件下载';
            splitterBody.innerHTML = `<div class="selection-header"><span><strong>原始文件 (过滤后):</strong> ${originalTotalFiles} 个, ${this.formatBytes(originalTotalSize)}</span><button id="back-to-splitter-btn" class="link-style-btn">返回修改</button></div><div class="chunk-list">${chunkListItems}</div><div class="selection-summary"><span>已选择: 0 个文件, 0 Bytes</span></div>`;
            splitterActions.innerHTML = `
                <button id="download-seq-btn" class="download-action-btn btn-green">顺序下载选中项 (推荐)</button>
                <button id="download-ind-btn" class="download-action-btn btn-grey">单独下载选中项</button>
                <button id="download-zip-btn" class="download-action-btn btn-blue">打包下载全部 (ZIP)</button>
            `;

            const allCheckboxes = splitterBody.querySelectorAll('.chunk-checkbox');
            const selectionSummaryEl = splitterBody.querySelector('.selection-summary > span');
            const updateSelectionSummary = () => {
                const selectedChunks = [...allCheckboxes].filter(cb => cb.checked).map(cb => chunks[cb.value]);
                const selectedFileCount = selectedChunks.reduce((sum, chunk) => sum + chunk.data.files.length, 0);
                const selectedSize = selectedChunks.reduce((sum, chunk) => sum + chunk.data.totalSize, 0);
                selectionSummaryEl.textContent = `已选择: ${selectedFileCount} 个文件, ${this.formatBytes(selectedSize)}`;
            };

            const selectAllCheckbox = document.createElement('input');
            selectAllCheckbox.type = 'checkbox'; selectAllCheckbox.checked = true;
            selectAllCheckbox.onchange = () => { allCheckboxes.forEach(cb => cb.checked = selectAllCheckbox.checked); updateSelectionSummary(); };
            allCheckboxes.forEach(cb => { cb.addEventListener('change', () => { selectAllCheckbox.checked = [...allCheckboxes].every(c => c.checked); updateSelectionSummary(); }); });
            const selectAllLabel = document.createElement('label');
            selectAllLabel.appendChild(selectAllCheckbox); selectAllLabel.append(' 全选/全不选');
            splitterBody.querySelector('.selection-header').prepend(selectAllLabel);
            updateSelectionSummary();

            splitterBody.querySelector('#back-to-splitter-btn').onclick = () => {
                splitterTitle.textContent = originalTitle; splitterBody.innerHTML = originalBodyHTML; splitterActions.innerHTML = originalActionsHTML;
                this.rebindMainViewEvents(modalContainer);
            };

            const downloadZip = (chunksToZip, zipFilename) => {
                try {
                    if (typeof JSZip === 'undefined') { this.showError("错误:ZIP库未能加载,请检查网络或浏览器插件冲突。"); return; }
                    this.showProgressModal('正在生成ZIP压缩包,请稍候...');
                    const zip = new JSZip();
                    chunksToZip.forEach(chunk => zip.file(chunk.filename, JSON.stringify(chunk.data, null, 2)));
                    zip.generateAsync({ type: "blob", compression: "DEFLATE" })
                        .then(blob => { this.hideProgressModal(); this._downloadToFile(blob, zipFilename, 'application/zip'); this.showCompletionSummary(originalFileName, originalFilteredData, chunksToZip); })
                        .catch(err => { this.hideProgressModal(); this.showError(`ZIP打包失败: ${err.message}`); console.error(err); });
                } catch(e) { this.hideProgressModal(); this.showError(`下载操作失败: ${e.message}`); console.error(e); }
            };

            const downloadSequentially = async (chunksToDownload) => {
                const delay = ms => new Promise(res => setTimeout(res, ms));
                for (let i = 0; i < chunksToDownload.length; i++) {
                    const chunk = chunksToDownload[i];
                    this.showProgressModal(`正在下载: ${chunk.filename} (${i + 1}/${chunksToDownload.length})`);
                    this._downloadToFile(JSON.stringify(chunk.data, null, 2), chunk.filename, 'application/json');
                    await delay(SEQUENTIAL_DOWNLOAD_DELAY);
                }
                this.hideProgressModal();
                this.showCompletionSummary(originalFileName, originalFilteredData, chunksToDownload);
            };

            splitterActions.querySelector('#download-ind-btn').onclick = () => {
                const selectedChunks = [...allCheckboxes].filter(cb => cb.checked).map(cb => chunks[cb.value]);
                if (selectedChunks.length === 0) { this.showError("请至少选择一个文件。"); return; }
                if (selectedChunks.length >= BROWSER_DOWNLOAD_LIMIT) { this.showError(`选择文件过多(${selectedChunks.length}个),可能被浏览器拦截,建议使用“顺序下载”。`, 5000); }
                selectedChunks.forEach(chunk => this._downloadToFile(JSON.stringify(chunk.data, null, 2), chunk.filename, 'application/json'));
                this.showCompletionSummary(originalFileName, originalFilteredData, selectedChunks);
            };
            splitterActions.querySelector('#download-seq-btn').onclick = () => {
                const selectedChunks = [...allCheckboxes].filter(cb => cb.checked).map(cb => chunks[cb.value]);
                if (selectedChunks.length === 0) { this.showError("请至少选择一个文件。"); return; }
                downloadSequentially(selectedChunks);
            };
            splitterActions.querySelector('#download-zip-btn').onclick = () => downloadZip(chunks, `${baseFileName}_all.zip`);

            if (typeof JSZip === 'undefined') {
                const zipBtn = splitterActions.querySelector('#download-zip-btn');
                zipBtn.disabled = true; zipBtn.title = "ZIP库加载失败,此功能不可用";
            }
        },
        rebindMainViewEvents: function(modalContainer) {
            this.fileContent = this.fileContent || null;
            this.originalFileName = this.originalFileName || '';
            this.originalFilteredData = this.originalFilteredData || null;

            const dropArea = modalContainer.querySelector('#splitter-drop-area');
            const fileInput = modalContainer.querySelector('#splitter-file-input');
            const browseBtn = modalContainer.querySelector('#splitter-browse-btn');
            const analyzeBtn = modalContainer.querySelector('#splitter-analyze-btn');
            const startBtn = modalContainer.querySelector('#splitter-start-btn');
            const radioButtons = modalContainer.querySelectorAll('input[name="split-method"]');

            const handleFile = (file) => {
                const statusDiv = modalContainer.querySelector('#splitter-file-status');
                if (file && file.name.endsWith('.json')) {
                    const reader = new FileReader();
                    reader.onload = (e) => {
                        this.fileContent = e.target.result; this.originalFileName = file.name;
                        statusDiv.textContent = `已加载: ${file.name}`;
                        analyzeBtn.disabled = false; startBtn.disabled = false;
                        modalContainer.querySelector('#splitter-analysis-result').style.display = 'none';
                    };
                    reader.readAsText(file, 'UTF-8');
                } else {
                    this.showError("请选择一个有效的 .json 文件。");
                    this.fileContent = null; this.originalFileName = '';
                    statusDiv.textContent = ''; analyzeBtn.disabled = true; startBtn.disabled = true;
                }
            };

            browseBtn.onclick = () => fileInput.click();
            ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
                if(dropArea.getAttribute('listener') !== 'true') {
                    dropArea.addEventListener(eventName, e => {
                        e.preventDefault(); e.stopPropagation();
                        if (eventName === 'dragover') dropArea.classList.add('drag-over');
                        if (eventName === 'dragleave' || eventName === 'drop') dropArea.classList.remove('drag-over');
                        if (eventName === 'drop') { handleFile(e.dataTransfer.files[0]); }
                    });
                }
            });
            dropArea.setAttribute('listener', 'true'); // Prevent re-adding listeners

            fileInput.onchange = (e) => handleFile(e.target.files[0]);
            analyzeBtn.onclick = () => this.analyzeHandler(modalContainer);
            startBtn.onclick = () => this.startHandler(modalContainer);
            const toggleInputs = () => {
                const selectedMethod = modalContainer.querySelector('input[name="split-method"]:checked').value;
                modalContainer.querySelector('#splitter-level-container').style.display = (selectedMethod === 'byFolder') ? 'inline-block' : 'none';
                modalContainer.querySelector('#splitter-chunk-size-container').style.display = (selectedMethod === 'byCount') ? 'inline-block' : 'none';
            };
            radioButtons.forEach(radio => radio.onchange = toggleInputs);
            toggleInputs();
        },
        showSplitterTool: function() {
            if (document.getElementById('splitter-modal-overlay')) return;
            this.applyStyles();
            const overlay = document.createElement('div');
            overlay.id = 'splitter-modal-overlay';
            overlay.className = 'splitter-overlay';
            const modalContainer = document.createElement('div');
            modalContainer.className = 'splitter-container';
            modalContainer.innerHTML = `
                <span id="splitter-modal-close-btn" class="splitter-modal-close-btn">&times;</span>
                <h1 class="splitter-title">${SCRIPT_NAME_SPLITTER}</h1>
                <div class="splitter-body">
                     <div id="splitter-drop-area" class="splitter-drop-area">
                        <p>拖拽一个 .json 文件到此处 或 <button id="splitter-browse-btn" class="link-style-btn">点击选择文件</button></p>
                        <input type="file" id="splitter-file-input" accept=".json" style="display: none;">
                        <p id="splitter-file-status"></p>
                    </div>
                    <div style="text-align:center; margin-bottom: 1.5rem;"><button id="splitter-analyze-btn" class="button-secondary" style="margin:0;">📊 分析JSON结构</button></div>
                    <div id="splitter-analysis-result" style="display: none;"></div>
                    <div class="splitter-options">
                         <strong>选择拆分模式:</strong>
                         <div><label><input type="radio" name="split-method" value="byFolder" checked> 按目录层级</label> <span id="splitter-level-container"><label>层数: <input type="number" id="splitter-level" value="1" min="1" style="width: 60px;"></label></span></div>
                         <div><label><input type="radio" name="split-method" value="byCount"> 按文件数量</label> <span id="splitter-chunk-size-container"><label><input type="number" id="splitter-chunk-size" value="500" min="1" style="width: 80px;"> 个文件/份</label></span></div>
                    </div>
                    <div class="splitter-filter-options">
                         <strong>元数据过滤设置 (可选):</strong>
                         <div>
                             <label style="margin-right: 10px;"><input type="checkbox" class="filter-ext" value="nfo">.nfo</label>
                             <label style="margin-right: 10px;"><input type="checkbox" class="filter-ext" value="jpg,jpeg">.jpg/.jpeg</label>
                             <label style="margin-right: 10px;"><input type="checkbox" class="filter-ext" value="png">.png</label>
                             <label>自定义: <input type="text" id="custom-filter-extensions" placeholder="txt,url (逗号隔开)" style="width: 180px;"></label>
                         </div>
                    </div>
                </div>
                <div class="splitter-actions">
                     <button id="splitter-start-btn" class="button-primary" disabled>🚀 开始拆分</button>
                </div>
                <div class="splitter-footer">v${SCRIPT_VERSION} <span style="margin-left: 15px;">鸣谢: @一只氧气</span></div>
            `;
            document.body.appendChild(overlay);
            overlay.appendChild(modalContainer);
            modalContainer.querySelector('#splitter-modal-close-btn').onclick = () => overlay.remove();
            this.rebindMainViewEvents(modalContainer);
        },
        showMergerTool: function() {
            if (document.getElementById('splitter-modal-overlay')) return;
            this.applyStyles();
            const overlay = document.createElement('div');
            overlay.id = 'splitter-modal-overlay';
            overlay.className = 'splitter-overlay';
            const modalContainer = document.createElement('div');
            modalContainer.className = 'splitter-container';
            modalContainer.innerHTML = `
                <span id="splitter-modal-close-btn" class="splitter-modal-close-btn">&times;</span>
                <h1 class="splitter-title">${SCRIPT_NAME_MERGER}</h1>
                <div class="splitter-body">
                     <div id="merger-drop-area" class="splitter-drop-area">
                         <p>拖拽多个 .json 文件到此处</p>
                         <p style="font-size: 0.9em; color: #999;">或</p>
                         <input type="file" id="merger-file-input" accept=".json" multiple style="display: none;">
                         <button id="merger-browse-btn" class="button-secondary" style="margin:0;">点击选择文件</button>
                         <p id="merger-file-status"></p>
                     </div>
                     <div id="merger-file-list-container" style="display:none;">
                         <strong>选择要合并的文件 (第一个将作为元数据基准):</strong>
                         <div class="chunk-list" id="merger-file-list"></div>
                         <div class="merger-summary"><span>已选择: 0 个文件, 0 Bytes</span></div>
                     </div>
                </div>
                <div class="splitter-actions">
                     <button id="merger-start-btn" class="button-primary" disabled>🔄 合并并下载</button>
                </div>
                <div class="splitter-footer">v${SCRIPT_VERSION} <span style="margin-left: 15px;">鸣谢: @一只氧气</span></div>
            `;
            document.body.appendChild(overlay);
            overlay.appendChild(modalContainer);
            modalContainer.querySelector('#splitter-modal-close-btn').onclick = () => overlay.remove();

            // --- Merger Logic ---
            let uploadedFiles = [];

            const dropArea = modalContainer.querySelector('#merger-drop-area');
            const fileInput = modalContainer.querySelector('#merger-file-input');
            const browseBtn = modalContainer.querySelector('#merger-browse-btn');
            const statusDiv = modalContainer.querySelector('#merger-file-status');
            const listContainer = modalContainer.querySelector('#merger-file-list-container');
            const fileListDiv = modalContainer.querySelector('#merger-file-list');
            const summaryEl = modalContainer.querySelector('.merger-summary > span');
            const startBtn = modalContainer.querySelector('#merger-start-btn');

            const renderFileList = () => {
                fileListDiv.innerHTML = uploadedFiles.map((file, index) => `
                    <div class="chunk-item">
                        <input type="checkbox" class="merger-checkbox" id="merger-checkbox-${index}" value="${index}" checked>
                        <label for="merger-checkbox-${index}"><strong>${file.name}</strong></label>
                        <span class="chunk-item-details">${file.data.files.length} 个文件, ${this.formatBytes(file.data.files.reduce((s,f) => s + (Number(f.size) || 0), 0))}</span>
                    </div>
                `).join('');
                listContainer.style.display = 'block';
                startBtn.disabled = uploadedFiles.length < 2;

                const allCheckboxes = fileListDiv.querySelectorAll('.merger-checkbox');
                const updateSummary = () => {
                    const selectedFiles = [...allCheckboxes].filter(cb => cb.checked).map(cb => uploadedFiles[cb.value]);
                    const totalFiles = selectedFiles.reduce((sum, f) => sum + f.data.files.length, 0);
                    const totalSize = selectedFiles.reduce((sum, f) => sum + f.data.files.reduce((s,i) => s + (Number(i.size) || 0), 0), 0);
                    summaryEl.textContent = `已选择: ${totalFiles} 个文件, ${this.formatBytes(totalSize)}`;
                    startBtn.disabled = selectedFiles.length < 2;
                };

                allCheckboxes.forEach(cb => cb.addEventListener('change', updateSummary));
                updateSummary();
            };

            const handleFiles = (files) => {
                if (files.length === 0) return;
                statusDiv.textContent = `正在加载 ${files.length} 个文件...`;
                uploadedFiles = [];
                const promises = [...files].map(file => new Promise((resolve, reject) => {
                    if (file.name.endsWith('.json')) {
                        const reader = new FileReader();
                        reader.onload = e => {
                            try {
                                const data = JSON.parse(e.target.result);
                                if (!data || !Array.isArray(data.files)) {
                                   return reject(`文件 ${file.name} 格式无效!`);
                                }
                                resolve({ name: file.name, data: data });
                            } catch (err) { reject(`文件 ${file.name} 解析失败!`); }
                        };
                        reader.onerror = () => reject(`读取文件 ${file.name} 失败!`);
                        reader.readAsText(file, 'UTF-8');
                    } else {
                        resolve(null);
                    }
                }));

                Promise.all(promises).then(results => {
                    uploadedFiles = results.filter(r => r !== null);
                    if (uploadedFiles.length > 0) {
                        statusDiv.textContent = `成功加载 ${uploadedFiles.length} 个JSON文件。`;
                        renderFileList();
                    } else {
                        this.showError("未能成功加载任何有效的JSON文件。");
                        statusDiv.textContent = '';
                    }
                }).catch(error => {
                    this.showError(error);
                    statusDiv.textContent = '加载失败。';
                });
            };

            browseBtn.onclick = () => fileInput.click();
            fileInput.onchange = (e) => handleFiles(e.target.files);
            dropArea.ondragover = (e) => { e.preventDefault(); e.stopPropagation(); dropArea.classList.add('drag-over'); };
            dropArea.ondragleave = (e) => { e.preventDefault(); e.stopPropagation(); dropArea.classList.remove('drag-over'); };
            dropArea.ondrop = (e) => { e.preventDefault(); e.stopPropagation(); dropArea.classList.remove('drag-over'); handleFiles(e.dataTransfer.files); };

            startBtn.onclick = () => {
                const allCheckboxes = fileListDiv.querySelectorAll('.merger-checkbox');
                const selectedFiles = [...allCheckboxes].filter(cb => cb.checked).map(cb => uploadedFiles[cb.value]);
                const mergedJson = coreLogic.mergeJsonFiles(selectedFiles);
                if (mergedJson) {
                    this.showAlert("合并成功!开始下载...", 3000);
                    this._downloadToFile(JSON.stringify(mergedJson, null, 2), "merged_files.json", "application/json");
                }
            };
        },
        /**
         * Creates a menu item element that mimics the native sidebar items.
         * @param {string} id - The ID for the new list item.
         * @param {string} text - The text to display for the menu item.
         * @param {string} iconHref - The SVG icon's xlink:href value.
         * @returns {HTMLLIElement} - The fully constructed list item element.
         */
        createToolMenuItem: function(id, text, iconHref) {
            const li = document.createElement('li');
            li.id = id;
            li.className = 'ant-menu-item ant-menu-item-only-child';
            li.setAttribute('role', 'menuitem');
            li.style.paddingLeft = '24px'; // Match native padding

            const span = document.createElement('span');
            span.className = 'ant-menu-title-content';

            const a = document.createElement('a');
            a.className = 'menu-item';
            a.href = '#'; // Use a dummy href to make it behave like a link

            const iconWrapper = document.createElement('div');
            iconWrapper.className = 'menu-icon-wrapper';

            const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            svg.setAttribute('class', 'icon menu-icon');
            svg.setAttribute('aria-hidden', 'true');

            const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
            use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', iconHref);

            svg.appendChild(use);
            iconWrapper.appendChild(svg);

            const textDiv = document.createElement('div');
            textDiv.className = 'menu-text';
            textDiv.textContent = text;

            a.appendChild(iconWrapper);
            a.appendChild(textDiv);
            span.appendChild(a);
            li.appendChild(span);

            return li;
        },

        /**
         * Injects tool links into the stable left sidebar menu.
         */
        injectSidebarTools: function() {
            const checkInterval = setInterval(() => {
                // More specific selector for the main menu, excluding the bottom one.
                const sidebarMenu = document.querySelector('.side-menu-container > ul.side-menu:not(.bottom-menu)');

                if (sidebarMenu && !document.getElementById('gm-tool-splitter-li')) {
                    // Define short names and icons for the menu items
                    const SHORT_NAME_SPLITTER = "JSON拆分";
                    const SHORT_NAME_MERGER = "JSON合并";
                    const splitterIcon = '#business_share_24_1'; // Use the "Share" icon - it's guaranteed to exist
                    const mergerIcon = '#business_toolcenter_24_1'; // Keep the tool icon

                    // --- Create and inject Splitter tool menu item ---
                    const splitterItem = this.createToolMenuItem('gm-tool-splitter-li', SHORT_NAME_SPLITTER, splitterIcon);
                    splitterItem.onclick = (e) => {
                        e.preventDefault();
                        this.showSplitterTool();
                    };
                    sidebarMenu.appendChild(splitterItem);

                     // --- Create and inject Merger tool menu item ---
                    const mergerItem = this.createToolMenuItem('gm-tool-merger-li', SHORT_NAME_MERGER, mergerIcon);
                    mergerItem.onclick = (e) => {
                        e.preventDefault();
                        this.showMergerTool();
                    };
                    sidebarMenu.appendChild(mergerItem);

                    clearInterval(checkInterval);
                }
            }, 1000);
        },
        fileContent: null,
        originalFileName: '',
        originalFilteredData: null,
        analyzeHandler: function(modalContainer) {
            const jsonData = this.getFilteredJsonData(modalContainer);
            if (!jsonData) { this.showError("请先加载JSON文件。"); return; }
            const analysis = coreLogic.analyzeJsonStructure(jsonData);
            const analysisResultDiv = modalContainer.querySelector('#splitter-analysis-result');
            if (analysis.error) { analysisResultDiv.innerHTML = `<span style="color:red;">分析失败: ${analysis.error}</span>`; }
            else { analysisResultDiv.innerHTML = `<strong>分析结果 (已过滤):</strong><br>- 总文件数: ${analysis.fileCount}<br>- 总大小: ${analysis.totalSize}<br><strong>目录结构预览:</strong><br>${analysis.treeString}`; }
            analysisResultDiv.style.display = 'block';
        },
        startHandler: function(modalContainer) {
            const jsonData = this.getFilteredJsonData(modalContainer);
            if (!jsonData) { this.showError("请先加载JSON文件。"); return; }
            this.originalFilteredData = jsonData;
            const method = modalContainer.querySelector('input[name="split-method"]:checked').value;
            const config = {};
            if (method === 'byFolder') { config.level = parseInt(modalContainer.querySelector('#splitter-level').value, 10); }
            else { config.chunkSize = parseInt(modalContainer.querySelector('#splitter-chunk-size').value, 10); }
            const result = coreLogic.splitImportedJsonFile(jsonData, this.originalFilteredData, this.originalFileName, method, config);
            if(result) { this.showDownloadSelectionView(result); }
        },
        getFilteredJsonData: function(modalContainer) {
            if (!this.fileContent) return null;
            let jsonData;
            try {
                jsonData = JSON.parse(this.fileContent);
            } catch(e) {
                this.showError("JSON文件解析失败,请检查文件内容是否正确。");
                return null;
            }

            const extensionsToFilter = new Set();
            modalContainer.querySelectorAll('.filter-ext:checked').forEach(cb => { cb.value.split(',').forEach(ext => extensionsToFilter.add(ext.trim().toLowerCase())); });
            const customInput = modalContainer.querySelector('#custom-filter-extensions').value;
            if (customInput) { customInput.split(',').forEach(ext => { const trimmedExt = ext.trim().toLowerCase(); if (trimmedExt) extensionsToFilter.add(trimmedExt); }); }

            if (extensionsToFilter.size > 0) {
                const originalCount = jsonData.files.length;
                jsonData.files = jsonData.files.filter(file => {
                    const parts = file.path.split('.');
                    if (parts.length < 2) return true;
                    const extension = parts.pop().toLowerCase();
                    return !extensionsToFilter.has(extension);
                });
                const filteredCount = originalCount - jsonData.files.length;
                if (filteredCount > 0) { uiManager.showAlert(`已根据您的设置过滤掉 ${filteredCount} 个文件。`, 2500); }
            }
            return jsonData;
        },
    };

    // --- Script Entry Point ---
    uiManager.applyStyles();
    uiManager.injectSidebarTools();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址