// ==UserScript== // @name 百度网盘转存助手(by Qi) // @namespace https://github.com/Wayne-Qi // @version 1.1.2 // @author Qi // @description 基于稳定内核,优化文件夹转存策略(预分块),支持自动建文件夹、记忆设置、完成提示音 // @supportURL tencent://message/?uin=544439919 // @match *://pan.baidu.com/disk/home* // @match *://yun.baidu.com/disk/home* // @match *://pan.baidu.com/disk/main* // @match *://yun.baidu.com/disk/main* // @match https://pan.baidu.com/s/* // @match https://yun.baidu.com/s/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_notification // @run-at document-end // @all-frames true // @license MIT // ==/UserScript== (function () { 'use strict'; // ================================================================ // 0. 辅助工具:提示音(Web Audio 生成,无需外部文件) // ================================================================ function playBeep() { try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value = 880; oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime + 0.3); } catch (e) {} } // ================================================================ // 1. 通用 HTTP 请求工具(替代 jQuery.ajax) // ================================================================ function httpRequest(url, method, data, withCredentials, raw) { const options = { method: method, headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: withCredentials ? 'include' : 'same-origin', }; if (data && method === 'POST') { options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; options.body = data; } return fetch(url, options).then(function (resp) { return resp.text().then(function (text) { // raw 模式:直接返回原始文本,不解析 JSON(用于获取 HTML 页面) if (raw) { if (!resp.ok) { throw new Error('HTTP ' + resp.status + ': ' + resp.statusText); } return text; } // 非 JSON 响应保护:百度可能返回 HTML 登录页或错误页 if (!resp.ok) { try { var errJson = JSON.parse(text); var errmsg = errJson.show_msg || errJson.errmsg || ('HTTP ' + resp.status); var err = new Error(errmsg); err.errno = errJson.errno; err.status = resp.status; throw err; } catch (parseErr) { if (parseErr.errno !== undefined) throw parseErr; throw new Error('HTTP ' + resp.status + ': ' + resp.statusText + (text.length < 200 ? ' ' + text : '')); } } try { return JSON.parse(text); } catch (e) { throw new Error('Invalid JSON response from API: ' + text.substring(0, 200)); } }); }); } // ================================================================ // 2. 核心转存类 // ================================================================ window.BaiduTransfer = function (rootPath) { this.ROOT_URL = 'https://pan.baidu.com'; this.bdstoken = null; this.shareId = null; this.shareRoot = null; this.userId = null; this.dirList = []; this.fileList = []; this.rootPath = rootPath || ""; this.maxRetries = 5; this.cancel = false; this.onProgress = null; this.onLog = null; this.totalFiles = 0; this.transferredFiles = 0; }; BaiduTransfer.prototype = { request: async function (path, method, params, data, checkErrno, raw) { var url = this.ROOT_URL + path; if (params) { url += '?' + params; } try { var response = await httpRequest(url, method, data, true, raw); if (!raw && checkErrno && response.errno && response.errno !== 0) { var errno = response.errno; var errmsg = response.show_msg || "过5分钟重试"; var customError = new Error(errmsg); customError.errno = errno; throw customError; } return response; } catch (error) { throw error; } }, createDirectory: async function (dirPath) { // 修复 #9:直接创建,忽略 errno=-9(已存在),去掉 listDir 前置检查避免 TOCTOU 竞态 try { var path = "/api/create"; var params = "a=commit&bdstoken=" + this.bdstoken; var data = "path=" + encodeURIComponent(dirPath) + "&isdir=1&block_list=[]"; await this.request(path, "POST", params, data, true); } catch (error) { // errno=-9 表示目录已存在,忽略 if (error.errno === -9) { return; } throw error; } }, listDir: async function (dirPath) { var path = "/api/list"; var params = "order=time&desc=1&showempty=0&page=1&num=1000&dir=" + this.customUrlEncode(dirPath) + "&bdstoken=" + this.bdstoken; return await this.request(path, "GET", params, null, true); }, transfer: async function (userId, shareId, fsidList, transferPath, retryCount) { if (retryCount === undefined) retryCount = 0; if (this.cancel) throw new Error('操作已取消'); var path = "/share/transfer"; var params = "shareid=" + shareId + "&from=" + userId + "&ondup=newcopy&channel=chunlei&bdstoken=" + this.bdstoken; var data = "fsidlist=[" + fsidList.join(",") + "]&path=" + encodeURIComponent(transferPath || "/"); try { var response = await this.request(path, "POST", params, data, false); var errno = response.errno; if (errno !== 0) { if (errno === 2) { throw new Error("APIParameterError: url=" + path + " param=" + params); } else if (errno === 12) { var limit = response.target_file_nums_limit; var count = response.target_file_nums; if (limit && count) { throw new Error("TransferLimitExceededException: limit=" + limit + " count=" + count); } throw new Error(response.show_msg || "Transfer limit exceeded"); } else if (errno === 1504) { if (this.onLog) this.onLog('\u23F3 超时重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')'); if (retryCount < this.maxRetries) { await new Promise(function (resolve) { setTimeout(resolve, 2000); }); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } else { throw new Error("Transfer timeout after maximum retries"); } } else if (errno === 111) { if (this.onLog) this.onLog('\u23F3 API限流,等待15秒... (' + (retryCount + 1) + '/' + this.maxRetries + ')'); if (retryCount < this.maxRetries) { await new Promise(function (resolve) { setTimeout(resolve, 15000); }); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } else { throw new Error("API rate limit exceeded after maximum retries"); } } else if (errno === -6 || errno === -9 || errno === 130) { if (this.onLog) this.onLog('\u23F3 网络错误,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')'); if (retryCount < this.maxRetries) { await new Promise(function (resolve) { setTimeout(resolve, 3000); }); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } else { throw new Error("Network/Server error after maximum retries: errno=" + errno); } } else { throw new Error("BaiduYunPanAPIException: [" + errno + "] " + (response.errmsg || response.show_msg || '')); } } this.transferredFiles += fsidList.length; if (this.onProgress) { this.onProgress(this.transferredFiles, this.totalFiles); } if (this.onLog) this.onLog('\u2705 成功转存 ' + fsidList.length + ' 个文件/文件夹'); return response; } catch (error) { if (isNoRetryError(error)) { throw error; } if (retryCount < this.maxRetries && (error.message.indexOf('timeout') !== -1 || error.message.indexOf('Network') !== -1)) { if (this.onLog) this.onLog('\u23F3 网络超时,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')'); await new Promise(function (resolve) { setTimeout(resolve, 3000); }); return await this.transfer(userId, shareId, fsidList, transferPath, retryCount + 1); } throw error; } }, getBdstoken: async function () { if (this.bdstoken) { return this.bdstoken; } var path = "/api/gettemplatevariable"; var params = "fields=[\"bdstoken\"]"; var response = await this.request(path, "GET", params, null, true); this.bdstoken = response.result.bdstoken; return this.bdstoken; }, getRandsk: async function (shareKey, pwd) { var path = "/share/verify"; var params = "surl=" + shareKey + "&bdstoken=" + this.bdstoken; var data = "pwd=" + encodeURIComponent(pwd); var response = await this.request(path, "POST", params, data, true); return response.randsk; }, getShareData: async function (shareKey, pwd) { var path = "/s/1" + shareKey; // 这个接口返回的是 HTML 页面(包含 locals.mset(...) 的 JSON 数据),需要 raw 模式获取原始文本 var response = await this.request(path, "GET", null, null, false, true); if (this.onLog) this.onLog('📄 分享页面获取成功,长度: ' + response.length + ' 字符'); // 查找 locals.mset( 标记 var tag = 'locals.mset('; var tagIndex = response.indexOf(tag); if (tagIndex === -1) { // 备用:尝试不带括号的 locals.mset tag = 'locals.mset'; tagIndex = response.indexOf(tag); if (tagIndex === -1) { if (this.onLog) this.onLog('⚠️ 页面中未找到 locals.mset,可能需要先验证提取码或链接已失效', 'warning'); throw new Error("Invalid response: unable to find locals.mset data. 百度页面结构可能已更新,请检查脚本版本"); } } // 从 tag 之后找到第一个 ( 作为 JSON 对象的起始 var openParen = response.indexOf('(', tagIndex); if (openParen === -1) { if (this.onLog) this.onLog('⚠️ locals.mset 后未找到 (,页面格式异常', 'warning'); throw new Error("Invalid response: locals.mset found but no opening parenthesis"); } var startIndex = openParen + 1; // 括号匹配:找到与 startIndex 处 ( 对应的 ) var depth = 1; var pos = startIndex; var inStr = false; var strChar = ''; while (pos < response.length && depth > 0) { var ch = response[pos]; if (inStr) { if (ch === '\\') { pos += 2; // 跳过转义字符(如 \" \\ 等) continue; } if (ch === strChar) { inStr = false; } } else { if (ch === '"' || ch === "'") { inStr = true; strChar = ch; } else if (ch === '(') { depth++; } else if (ch === ')') { depth--; } } pos++; } if (depth !== 0) { if (this.onLog) this.onLog('⚠️ 括号匹配失败(depth=' + depth + '),页面内容可能被截断', 'warning'); throw new Error("Invalid response: unable to parse share data JSON (unbalanced parentheses)"); } var jsonStr = response.substring(startIndex, pos - 1); // pos-1 排除末尾的 ) if (this.onLog) this.onLog('📝 JSON 数据提取成功,长度: ' + jsonStr.length + ' 字符'); var data; try { data = JSON.parse(jsonStr); } catch (parseError) { if (this.onLog) this.onLog('⚠️ JSON 解析失败: ' + parseError.message, 'warning'); // 尝试修复:有些百度页面的 JSON 外面包了一层函数调用,去掉尾部 }); var tryFixed = jsonStr; if (tryFixed.endsWith('})')) { tryFixed = tryFixed.substring(0, tryFixed.length - 2); } try { data = JSON.parse(tryFixed); if (this.onLog) this.onLog('📝 JSON 修复解析成功'); } catch (e2) { throw new Error("Invalid response: failed to parse share data JSON. " + parseError.message); } } if (!data || !data.share_uk || !data.shareid || !data.file_list || !Array.isArray(data.file_list) || data.file_list.length === 0) { if (this.onLog) this.onLog('⚠️ JSON 数据缺少必要字段: share_uk=' + (data && data.share_uk) + ' shareid=' + (data && data.shareid) + ' file_list=' + (data && data.file_list ? data.file_list.length : 'null'), 'warning'); throw new Error("Invalid response: share data is incomplete or missing required fields"); } return { userId: data.share_uk, shareId: data.shareid, bdstoken: data.bdstoken, shareRoot: data.file_list[0].parent_path, dirList: data.file_list.filter(function (e) { return e.isdir === 1; }).map(function (file) { return { id: file.fs_id, name: file.server_filename }; }), fileList: data.file_list.filter(function (e) { return e.isdir !== 1; }).map(function (file) { return { id: file.fs_id, name: file.server_filename }; }) }; }, updateRandsk: async function (shareKey, pwd) { await this.getBdstoken(); await this.getRandsk(shareKey, pwd); }, initShareData: async function (shareKey, pwd) { if (pwd) { await this.updateRandsk(shareKey, pwd); } try { var shareData = await this.getShareData(shareKey, pwd); this.userId = shareData.userId; this.shareId = shareData.shareId; this.bdstoken = shareData.bdstoken; this.shareRoot = shareData.shareRoot; this.dirList = shareData.dirList; this.fileList = shareData.fileList; this.totalFiles = this.dirList.length + this.fileList.length; } catch (error) { // 修复:增强错误检测逻辑,检测 errno 字段和关键词 var msg = error.message || ''; if (msg.indexOf('/share/init') !== -1 || error.errno === -9 || (msg.indexOf('not found') !== -1 && msg.indexOf('share') !== -1)) { if (pwd) { throw new Error("Wrong password: " + pwd); } else { throw new Error("Password not specified"); } } throw error; } }, transferFiles: async function (fileList, targetPath, retryCount) { if (retryCount === undefined) retryCount = 0; if (this.cancel) throw new Error('操作已取消'); // 仅子目录(非根路径)需要创建,根路径已在 transferFinal 中统一创建 if (targetPath && targetPath !== this.rootPath) { await this.createDirectory(targetPath); } var maxTransferCount = 100; var failedFiles = []; try { for (var i = 0; i < fileList.length; i += maxTransferCount) { if (this.cancel) throw new Error('操作已取消'); var batch = fileList.slice(i, i + maxTransferCount); var fsidList = batch.map(function (file) { return file.id; }); try { await this.transfer(this.userId, this.shareId, fsidList, targetPath); } catch (batchError) { // 修复 #11:单批次失败记录但不中断,继续转存后续批次 if (isNoRetryError(batchError)) { failedFiles = failedFiles.concat(batch); if (this.onLog) this.onLog('\u26A0\uFE0F 批次包含不可恢复错误,已记录跳过(' + batch.length + ' 个)', 'warning'); continue; } throw batchError; } } if (failedFiles.length > 0) { if (this.onLog) this.onLog('\u26A0\uFE0F ' + failedFiles.length + ' 个文件转存失败(空间不足或已删除),其余 ' + (fileList.length - failedFiles.length) + ' 个成功'); } if (this.onLog) this.onLog('\u2705 文件转存完成 (' + fileList.length + ' 个' + (failedFiles.length > 0 ? ',失败 ' + failedFiles.length + ' 个' : '') + ')'); } catch (error) { if (isNoRetryError(error)) { throw error; } if (retryCount < this.maxRetries && !this.cancel) { if (this.onLog) this.onLog('\u23F3 文件转存失败,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')'); await new Promise(function (resolve) { setTimeout(resolve, 5000); }); return await this.transferFiles(fileList, targetPath, retryCount + 1); } else { throw error; } } }, transferDirs: async function (dirList, targetPath, retryCount) { if (retryCount === undefined) retryCount = 0; if (this.cancel) throw new Error('操作已取消'); // 仅子目录(非根路径)需要创建,根路径已在 transferFinal 中统一创建 if (targetPath && targetPath !== this.rootPath) { await this.createDirectory(targetPath); } if (dirList.length === 0) { return; } const MAX_BATCH = 100; const chunks = []; for (let i = 0; i < dirList.length; i += MAX_BATCH) { chunks.push(dirList.slice(i, i + MAX_BATCH)); } if (this.onLog) this.onLog('\uD83D\uDCE6 文件夹分 ' + chunks.length + ' 批转存(每批\u2264100个)'); for (let ci = 0; ci < chunks.length; ci++) { if (this.cancel) throw new Error('操作已取消'); let chunk = chunks[ci]; try { await this.transfer(this.userId, this.shareId, chunk.map(function (dir) { return dir.id; }), targetPath); if (this.onLog) this.onLog('\u2705 成功转存 ' + chunk.length + ' 个文件夹'); } catch (error) { if (error.message.indexOf('TransferLimitExceededException:') !== -1) { if (this.onLog) this.onLog('\u26A0\uFE0F 批次超限,递归拆分 (' + chunk.length + ' 个)'); if (chunk.length >= 2) { var mid = Math.floor(chunk.length / 2); await this.transferDirs(chunk.slice(0, mid), targetPath, retryCount); await this.transferDirs(chunk.slice(mid), targetPath, retryCount); } else { var dir = chunk[0]; var dirPath = this.shareRoot; if (targetPath.length > this.rootPath.length) { dirPath += targetPath.slice(this.rootPath.length); } dirPath += '/' + dir.name; var subFiles = await this.listShareDir(this.userId, this.shareId, dirPath); var subDirList = subFiles.filter(function (file) { return file.isDirectory; }); var subFileList = subFiles.filter(function (file) { return !file.isDirectory; }); this.totalFiles += subDirList.length + subFileList.length; if (subDirList.length > 0) { await this.transferDirs(subDirList, targetPath + '/' + dir.name); } if (subFileList.length > 0) { await this.transferFiles(subFileList, targetPath + '/' + dir.name); } } } else { if (isNoRetryError(error)) { throw error; } // 修复 #1:重试时只重传当前失败的 chunk,而非整个 dirList if (retryCount < this.maxRetries && !this.cancel) { if (this.onLog) this.onLog('\u23F3 第 ' + (ci + 1) + ' 批文件夹转存失败,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')'); await new Promise(function (resolve) { setTimeout(resolve, 10000); }); await this.transferDirs(chunk, targetPath, retryCount + 1); } else { throw error; } } } } }, listShareDir: async function (userId, shareId, dirPath, retryCount) { if (retryCount === undefined) retryCount = 0; var path = "/share/list"; var page = 1; var result = []; try { while (true) { var params = "uk=" + userId + "&shareid=" + shareId + "&order=name&desc=0&showempty=0&page=" + page + "&num=100&dir=" + this.customUrlEncode(dirPath); var response = await this.request(path, "GET", params, null, true); var list = response.list; if (!list || !Array.isArray(list)) { break; } list.forEach(function (item) { result.push({ id: item.fs_id, name: item.server_filename, isDirectory: item.isdir === 1 }); }); if (list.length < 100) { break; } page++; } return result; } catch (error) { if (retryCount < this.maxRetries) { if (this.onLog) this.onLog('\u23F3 列表获取失败,重试 (' + (retryCount + 1) + '/' + this.maxRetries + ')'); await new Promise(function (resolve) { setTimeout(resolve, 3000); }); return await this.listShareDir(userId, shareId, dirPath, retryCount + 1); } else { throw error; } } }, extractShareKey: function (url) { try { if (!url) return null; // 修复 #2:先解码,再去 # 后面的内容,避免 %23 等编码字符干扰 var decodedUrl = decodeURIComponent(url); var cleanUrl = decodedUrl.split('#')[0]; if (cleanUrl.indexOf("/s/1") !== -1) { return cleanUrl.split("/s/1")[1].split("?")[0]; } else if (cleanUrl.indexOf("surl=") !== -1) { return cleanUrl.split("surl=")[1].split("&")[0]; } } catch (e) { // 解码失败时回退到原始 URL 的简单解析 try { var fallbackUrl = url.split('#')[0]; if (fallbackUrl.indexOf("/s/1") !== -1) { return fallbackUrl.split("/s/1")[1].split("?")[0]; } else if (fallbackUrl.indexOf("surl=") !== -1) { return fallbackUrl.split("surl=")[1].split("&")[0]; } } catch (e2) {} } return null; }, customUrlEncode: function (input) { let encoded = ''; for (let i = 0; i < input.length; i++) { let c = input[i]; if (c === ' ' || c === '"' || c === '\'') { encoded += encodeURIComponent(c); } else if (c === '+') { encoded += '%2B'; } else { encoded += c; } } return encoded; }, transferFinal: async function (url, pwd) { var shareKey = this.extractShareKey(url); if (!shareKey) { throw new Error("Unable to extract share key from URL"); } await this.initShareData(shareKey, pwd); this.transferredFiles = 0; // 统一创建目标目录(仅一次),避免 transfer API 的 ondup=newcopy 产生重复文件夹 if (this.rootPath) { await this.createDirectory(this.rootPath); } if (this.dirList.length > 0) { await this.transferDirs(this.dirList, this.rootPath); } if (this.fileList.length > 0) { await this.transferFiles(this.fileList, this.rootPath); } } }; // ================================================================ // 3. UI 控制面板 // ================================================================ const STATE = { IDLE: 'idle', RUNNING: 'running', COMPLETED: 'completed', ERROR: 'error', STOPPED: 'stopped' }; // 修复 #5:日志上限降低到 30 条 const MAX_LOG_ENTRIES = 30; let appState = { status: STATE.IDLE, totalFiles: 0, transferredFiles: 0, currentBatch: 0, totalBatches: 0, errorMessage: '', logMessages: [], }; let transferInstance = null; // 修复 #8:密码只保存到会话级内存,不持久化 function saveMemory(pwd, path) { // 密码仅保存到 sessionStorage(浏览器关闭后自动清除) try { if (pwd) sessionStorage.setItem('bt_pwd_session', pwd); } catch (e) { /* sessionStorage 不可用时忽略 */ } if (path) GM_setValue('saved_path', path); } function loadMemory() { let pwd = ''; try { pwd = sessionStorage.getItem('bt_pwd_session') || ''; } catch (e) { /* sessionStorage 不可用时忽略 */ } return { pwd: pwd, path: GM_getValue('saved_path', '') }; } function getDefaultPath() { const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); return '/转存_' + dateStr; } function isAutoDatePath(path) { return /^\/转存_\d{4}-\d{2}-\d{2}$/.test(path || ''); } function normalizeTargetPath(path) { path = (path || '').trim(); if (!path) return ''; if (!path.startsWith('/')) { path = '/' + path; } return path.replace(/\/+/g, '/'); } function isNoRetryError(error) { const msg = error && error.message ? error.message : String(error || ''); return msg.indexOf('剩余空间不足') !== -1 || msg.indexOf('空间不足') !== -1 || msg.indexOf('容量不足') !== -1 || msg.toLowerCase().indexOf('insufficient storage') !== -1; } function friendlyErrorMessage(error) { const msg = error && error.message ? error.message : String(error || ''); if (msg.indexOf('剩余空间不足') !== -1 || msg.indexOf('空间不足') !== -1 || msg.indexOf('容量不足') !== -1 || msg.toLowerCase().indexOf('insufficient storage') !== -1) { return '目标网盘剩余空间不足,请清理空间或更换账号后重试'; } if (msg.indexOf('TransferLimitExceededException') !== -1) { return '单批文件夹内容超限,脚本已尝试自动拆分;如果仍失败,请换更小的目录重试'; } if (msg.indexOf('timeout') !== -1 || msg.indexOf('Network') !== -1) { return '网络超时或百度接口响应慢,请稍后重试'; } if (msg.indexOf('rate limit') !== -1 || msg.indexOf('限流') !== -1) { return '百度接口限流,请等待一段时间后再试'; } if (msg.indexOf('Wrong password') !== -1) { return '提取码错误,请检查后重试'; } if (msg.indexOf('Password not specified') !== -1) { return '该分享需要提取码,请填写后重试'; } if (msg.indexOf('Unable to extract share key') !== -1) { return '无法识别分享链接,请检查链接是否完整'; } if (msg.indexOf('locals.mset') !== -1 || msg.indexOf('parse') !== -1) { return '百度页面结构可能已更新,请检查脚本是否有新版本'; } return msg || '未知错误'; } // 修复 #4+5:增量 DOM 更新替代全量 innerHTML function addLog(message, type) { if (!type) type = 'info'; const time = new Date().toLocaleTimeString(); const logEntry = { time: time, message: message, type: type }; appState.logMessages.unshift(logEntry); // 超限时移除数组末尾元素 while (appState.logMessages.length > MAX_LOG_ENTRIES) { appState.logMessages.pop(); } // 增量 DOM:仅在头部插入新条目 const logContent = document.getElementById('log-content'); if (logContent) { const cls = type === 'error' ? 'log-error' : type === 'success' ? 'log-success' : type === 'warning' ? 'log-warning' : 'log-info'; const div = document.createElement('div'); div.className = 'log-item ' + cls; div.innerHTML = '[' + time + '] ' + escapeHtml(message); logContent.insertBefore(div, logContent.firstChild); // 移除超出上限的尾部 DOM 节点 while (logContent.children.length > MAX_LOG_ENTRIES) { logContent.removeChild(logContent.lastChild); } logContent.scrollTop = 0; } // 更新统计和状态 UI(不重渲染日志) updateStatsUI(); console.log('[转存助手]', message); } function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } function updateStatsUI() { const totalEl = document.getElementById('stat-total'); const transferredEl = document.getElementById('stat-transferred'); const batchEl = document.getElementById('stat-batch'); const progressFill = document.getElementById('progress-fill'); const progressPercent = document.getElementById('progress-percent'); const statusBadge = document.getElementById('status-badge'); const statusText = document.getElementById('status-text'); const startBtn = document.getElementById('btn-start'); const stopBtn = document.getElementById('btn-stop'); if (totalEl) totalEl.textContent = appState.totalFiles || 0; if (transferredEl) transferredEl.textContent = appState.transferredFiles || 0; if (batchEl) batchEl.textContent = (appState.currentBatch || 0) + '/' + (appState.totalBatches || 0); const progress = appState.totalFiles > 0 ? (appState.transferredFiles / appState.totalFiles * 100) : 0; if (progressFill) progressFill.style.width = progress.toFixed(1) + '%'; if (progressPercent) progressPercent.textContent = progress.toFixed(1) + '%'; var statusMap = {}; statusMap[STATE.IDLE] = { text: '就绪', cls: 'status-idle' }; statusMap[STATE.RUNNING] = { text: '运行中', cls: 'status-running' }; statusMap[STATE.COMPLETED] = { text: '✅ 已完成', cls: 'status-completed' }; statusMap[STATE.ERROR] = { text: '❌ 错误', cls: 'status-error' }; statusMap[STATE.STOPPED] = { text: '⏹ 已停止', cls: 'status-idle' }; var info = statusMap[appState.status] || statusMap[STATE.IDLE]; if (statusBadge) { statusBadge.textContent = info.text; statusBadge.className = 'status-badge ' + info.cls; } if (statusText) { statusText.textContent = appState.errorMessage || ''; } if (startBtn && stopBtn) { if (appState.status === STATE.IDLE || appState.status === STATE.COMPLETED || appState.status === STATE.ERROR || appState.status === STATE.STOPPED) { startBtn.style.display = 'inline-block'; stopBtn.style.display = 'none'; startBtn.textContent = appState.status === STATE.COMPLETED ? '🔄 重新开始' : '▶ 开始转存'; startBtn.disabled = false; } else if (appState.status === STATE.RUNNING) { startBtn.style.display = 'none'; stopBtn.style.display = 'inline-block'; stopBtn.disabled = false; } } } // 全量 UI 更新(仅在清空日志等需要重绘时使用) function updateUI() { updateStatsUI(); const logContent = document.getElementById('log-content'); if (logContent) { // 先清空再重建(仅在需要时调用) var html = ''; for (var i = 0; i < appState.logMessages.length; i++) { var log = appState.logMessages[i]; var cls = log.type === 'error' ? 'log-error' : log.type === 'success' ? 'log-success' : log.type === 'warning' ? 'log-warning' : 'log-info'; html += '