// ==UserScript==
// @name Gemini 全量备份工具增强版 (Gemini Chat Export Enhanced)
// @namespace http://tampermonkey.net/
// @version 13.11.0
// @description 基于 Sxuan-Coder/gemini_chat_export 早期版本深度定制,新增批量导出/暂停/中断/可调参数/表格渲染等功能。适配 Gemini 新版 UI。
// @author Sxuan-Coder (Original) / AXIRYYYY (Enhanced Fork)
// @match https://gemini.google.com/*
// @grant GM_addStyle
// @icon https://www.gstatic.com/images/branding/product/1x/gemini_gradient_icon_48dp.png
// @license Apache-2.0
// @homepage https://github.com/AXIRYYYY/gemini-chat-export-enhanced
// @supportURL https://github.com/Sxuan-Coder/AIhubEnhenced/releases
// @updateURL https://raw.githubusercontent.com/AXIRYYYY/gemini-chat-export-enhanced/main/gemini-chat-export-enhanced.user.js
// @downloadURL https://raw.githubusercontent.com/AXIRYYYY/gemini-chat-export-enhanced/main/gemini-chat-export-enhanced.user.js
// ==/UserScript==
(function () {
'use strict';
// --- ⚙️ 配置中心 ---
const CONFIG = {
mainScrollStep: 1.5,
batchPageLoad: 4000,
uiZIndex: 999999
};
const State = {
isWorking: false,
stopSignal: false,
pauseSignal: false, // [MODIFIED] 新增暂停状态
abandonSignal: false, // [v13.11] 放弃任务标志,置 true 时跳过所有下载
collectedData: new Map(),
sidebarQueue: new Map(),
format: 'md',
scrollDelay: 300,
historyDelay: 1500
};
const DOM = {
el: (tag, styles = {}, text = '') => {
const el = document.createElement(tag);
Object.assign(el.style, styles);
if (text) el.innerText = text;
return el;
},
// --- 🔥 核心引擎:递归 DOM 解析器 (v13.7 Fix) 🔥 ---
recursiveParse: (node) => {
if (!node) return "";
// 1. 文本节点
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
// 2. 元素节点
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
// 代码块
if (tag === 'code-block' || tag === 'pre') {
const lang = node.getAttribute('data-language') || '';
return `\n\n\`\`\`${lang}\n${node.textContent.trim()}\n\`\`\`\n\n`;
}
// --- 表格处理 ---
if (tag === 'table') {
let tableMd = "\n\n";
// 穿透抓取所有行
const rows = Array.from(node.querySelectorAll('tr'));
if (rows.length === 0) return "";
rows.forEach((tr, rowIndex) => {
const cells = Array.from(tr.querySelectorAll('td, th'));
const rowContent = "| " + cells.map(c => {
// 递归解析单元格 (此时 c 是 td/th,下面必须允许递归)
let cellText = DOM.recursiveParse(c).trim();
cellText = cellText.replace(/\|/g, '\\|');
cellText = cellText.replace(/[\r\n]+/g, '
');
return cellText;
}).join(" | ") + " |";
tableMd += rowContent + "\n";
if (rowIndex === 0) {
const separator = "| " + cells.map(() => "---").join(" | ") + " |";
tableMd += separator + "\n";
}
});
return tableMd + "\n";
}
// 递归子节点
let childrenText = "";
Array.from(node.childNodes).forEach(child => {
// 🔥 关键修复:从黑名单中移除 td 和 th
// 只有 tr, thead, tbody 这些纯结构标签不需要在这里递归(因为 table 逻辑已经处理了它们)
// td 和 th 必须递归,否则无法读取里面的 span/text
if (!['tr', 'thead', 'tbody', 'table'].includes(tag)) {
childrenText += DOM.recursiveParse(child);
}
});
// --- 标签转换 ---
if (tag === 'strong' || tag === 'b') {
const trimmed = childrenText.trim();
if (!trimmed) return "";
const prefix = childrenText.match(/^\s*/) ? childrenText.match(/^\s*/)[0] : "";
const suffix = childrenText.match(/\s*$/) ? childrenText.match(/\s*$/)[0] : "";
return `${prefix}**${trimmed}**${suffix}`;
}
if (tag === 'em' || tag === 'i') {
const trimmed = childrenText.trim();
if (!trimmed) return "";
return ` *${trimmed}* `;
}
if (tag === 'a') {
const href = node.getAttribute('href');
if (href) return ` [${childrenText.trim()}](${href}) `;
return childrenText;
}
if (/^h[1-6]$/.test(tag)) {
const level = parseInt(tag[1]);
return `\n\n${'#'.repeat(level)} ${childrenText.trim()}\n\n`;
}
if (tag === 'p') return `\n\n${childrenText.trim()}\n\n`;
if (tag === 'li') return `\n- ${childrenText.trim()}\n`;
if (tag === 'br') return ' \n';
return childrenText;
}
return "";
},
parseMarkdown: (rootNode) => {
if (!rootNode) return "";
let text = DOM.recursiveParse(rootNode);
// 后期清洗
text = text.replace(/^\s*-\s+[\r\n]+/gm, '- ');
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
},
// ============================================================
// [v13.92 修改] 适配 Gemini 新 UI 的侧边栏对话列表选择器
// 旧 UI: 对话项使用 .conversation 类,标题使用 .conversation-title 类
// 新 UI: 对话项使用 gem-nav-list-item[data-test-id="conversation"] 自定义元素
// 标题使用 .title-text 类
// ============================================================
getChatItems: (debug = false) => {
// [v13.92] 修改选择器: .conversation → gem-nav-list-item[data-test-id="conversation"]
const items = Array.from(document.querySelectorAll('gem-nav-list-item[data-test-id="conversation"]'));
const results = [];
items.forEach((el, index) => {
// [v13.92] 修改标题选择器: .conversation-title → .title-text
if (!el.querySelector('.title-text')) return;
const titleEl = el.querySelector('.title-text');
let title = titleEl ? titleEl.innerText : "未命名对话";
title = title.replace(/[\r\n]/g, "").trim();
const jslog = el.getAttribute('jslog') || "";
const idMatch = jslog.match(/c_[a-f0-9]+/);
let baseId = idMatch ? idMatch[0] : "unknown";
const uniqueFingerprint = `${baseId}_${title.substring(0, 5)}_${index}`;
if (debug) el.style.border = "2px solid #00ff00";
const linkEl = el.querySelector('a');
results.push({ el: el, linkEl: linkEl, id: uniqueFingerprint, title: title });
});
return results;
},
findMainScroller: () => {
// [v13.12] 从对话元素向上遍历,退路启发式扫描,绝不返回 window
const chatEl = document.querySelector('user-query, model-response');
if (chatEl) {
let el = chatEl.parentElement;
while (el && el !== document.body) {
const s = window.getComputedStyle(el);
if (['auto', 'scroll'].includes(s.overflowY) && el.scrollHeight > el.clientHeight + 50) {
return el;
}
el = el.parentElement;
}
}
// 退路:启发式扫描所有容器,找最大的非侧边栏可滚动元素
let max = 0, target = null;
document.querySelectorAll('div, main, infinite-scroller').forEach(el => {
if (el.offsetParent && el.scrollHeight > el.clientHeight + 50) {
const s = window.getComputedStyle(el);
if (['auto', 'scroll'].includes(s.overflowY)) {
const rect = el.getBoundingClientRect();
if (rect.left > 200 && el.scrollHeight > max) { max = el.scrollHeight; target = el; }
}
}
});
return target || document.documentElement;
},
// ============================================================
// [v13.9 修改] 适配 Gemini 新 UI 的"当前选中对话"选择器
// 旧 UI: 当前对话使用 .conversation.selected 类
// 新 UI: 当前对话的 链接上同时有 is-active 和 aria-current="page"
// is-active 在 元素上,不在父级 gem-nav-list-item 上
// 标题使用 .title-text 类
// ============================================================
getTitle: () => {
// [v13.9] 选择器改为查找 标签上的 is-active + aria-current="page"
// 因为 is-active 类在 元素上,而非父级 gem-nav-list-item
const selectedItem = document.querySelector('a.is-active[aria-current="page"]');
if (selectedItem) {
// [v13.92] 标题选择器: .conversation-title → .title-text(保持不变)
const titleEl = selectedItem.querySelector('.title-text');
if (titleEl) return titleEl.innerText.trim();
}
return null;
}
};
const Core = {
wait: ms => new Promise(r => setTimeout(r, ms)),
extractNodes: () => {
const nodes = document.querySelectorAll('user-query, model-response');
let count = 0;
nodes.forEach((node) => {
if (!State.collectedData.has(node)) {
const role = node.tagName.toLowerCase() === 'user-query' ? 'user' : 'model';
let content = '', thought = '';
if (role === 'user') {
content = DOM.parseMarkdown(node);
} else {
const tNode = node.querySelector('model-thoughts');
if (tNode) {
thought = DOM.parseMarkdown(tNode).replace(/Thinking Process|显示思路/gi, '').trim();
}
const txtNode = node.querySelector('.model-response-text') || node;
content = DOM.parseMarkdown(txtNode);
}
if (content || thought) {
State.collectedData.set(node, { el: node, role, content, thought });
count++;
}
}
});
return count;
},
captureCurrentChat: async () => {
State.collectedData.clear();
// [v13.12] 等新对话内容渲染到位再找 scroller,防止导航后空白期导致退路命中 window
let waitAttempts = 0;
while (!document.querySelector('user-query, model-response') && waitAttempts < 10 && !State.stopSignal) {
await Core.wait(300);
waitAttempts++;
}
const scroller = DOM.findMainScroller();
const isWindow = scroller === window || scroller === document.documentElement;
UI.log(`🔄 追溯历史 (延迟: ${State.historyDelay}ms)...`);
let noChangeCount = 0;
let lastScrollHeight = isWindow ? document.documentElement.scrollHeight : scroller.scrollHeight;
let loopCount = 0;
while (loopCount < 100 && !State.stopSignal) {
if (isWindow) window.scrollTo(0, 0); else scroller.scrollTop = 0;
await Core.wait(State.historyDelay);
const currentScrollHeight = isWindow ? document.documentElement.scrollHeight : scroller.scrollHeight;
if (currentScrollHeight > lastScrollHeight + 100) {
const diff = currentScrollHeight - lastScrollHeight;
UI.log(`📜 历史加载成功 (+${diff}px)...`);
lastScrollHeight = currentScrollHeight;
noChangeCount = 0;
} else {
noChangeCount++;
UI.log(`⚠️ 未变化 (${noChangeCount}/10),执行深蹲...`);
if (isWindow) window.scrollBy(0, 300); else scroller.scrollBy(0, 300);
await Core.wait(300);
if (isWindow) window.scrollTo(0, 0); else scroller.scrollTop = 0;
if (noChangeCount >= 10) {
UI.log('✅ 历史已全部加载');
break;
}
}
loopCount++;
}
if (!State.stopSignal) {
UI.log('🚀 极速抓取中...');
let sameH = 0;
while (!State.stopSignal) {
const newFound = Core.extractNodes();
if (newFound > 0) UI.log(`📦 新增 ${newFound} 条...`);
const top = isWindow ? window.scrollY : scroller.scrollTop;
const total = isWindow ? document.documentElement.scrollHeight : scroller.scrollHeight;
const view = isWindow ? window.innerHeight : scroller.clientHeight;
if (top + view >= total - 50) {
sameH++; if (sameH > 3) break;
} else {
sameH = 0;
const step = view * CONFIG.mainScrollStep;
if (isWindow) window.scrollBy({ top: step, behavior: 'auto' });
else scroller.scrollBy({ top: step, behavior: 'auto' });
}
await Core.wait(State.scrollDelay);
}
} else {
UI.log('⚡ 已中断,准备保存...');
}
if (!State.abandonSignal) Core.extractNodes();
UI.log(`✅ 抓取结束 (共 ${State.collectedData.size} 条)`);
},
download: (customTitle = null) => {
if (State.abandonSignal) {
UI.log('🛑 任务已放弃,跳过下载');
return;
}
if (State.collectedData.size === 0) {
UI.log('⚠️ 内容为空,跳过');
return;
}
const list = Array.from(State.collectedData.values()).sort((a, b) => {
const pos = a.el.compareDocumentPosition(b.el);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
let finalTitle = customTitle || DOM.getTitle();
if (!finalTitle || finalTitle.includes("未命名") || finalTitle === "Gemini" || finalTitle.length < 2) {
const firstUser = list.find(x => x.role === 'user');
finalTitle = firstUser ? firstUser.content.substring(0, 20).replace(/[\r\n]/g, '') : "Gemini_Backup";
}
finalTitle = finalTitle.replace(/[\\/:\*\?"<>\|]/g, '_').substring(0, 60);
const now = new Date();
const time = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
let str = '', mime = '', ext = '';
if (State.format === 'json') {
str = JSON.stringify({ title: finalTitle, date: time, data: list }, null, 2);
mime = 'application/json'; ext = 'json';
} else if (State.format === 'txt') {
mime = 'text/plain'; ext = 'txt';
str = `Title: ${finalTitle}\nDate: ${time}\n\n`;
list.forEach(d => str += `[${d.role}]\n${d.thought ? `{Thought: ${d.thought}}\n` : ''}${d.content}\n\n---\n\n`);
} else {
mime = 'text/markdown'; ext = 'md';
str = `# ${finalTitle}\n\n> Date: ${time}\n\n`;
list.forEach(d => {
const r = d.role === 'user' ? 'User' : 'Gemini';
if (d.thought) str += `🧠 Thinking
\n\n${d.thought}\n\n \n\n`;
str += `### **${r}**\n\n${d.content}\n\n---\n\n`;
});
}
const blob = new Blob([str], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${finalTitle}_${time}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
UI.log(`💾 已保存: ${finalTitle}`);
},
accumulateScan: () => {
const items = DOM.getChatItems(true);
let newCount = 0;
items.forEach(item => {
if (!State.sidebarQueue.has(item.id)) {
State.sidebarQueue.set(item.id, item);
newCount++;
}
});
UI.log(`🔍 扫描: 新增 ${newCount} 个,队列总计: ${State.sidebarQueue.size} 个`);
UI.updateBatchBtn();
},
runBatchFromQueue: async () => {
if (State.isWorking) return;
if (State.sidebarQueue.size === 0) {
Core.accumulateScan();
if (State.sidebarQueue.size === 0) {
alert('队列为空。请先手动滚动侧边栏,然后点击【🔍 扫描】。');
return;
}
}
const allChats = Array.from(State.sidebarQueue.values());
const confirmed = confirm(`📦 队列中共有 ${allChats.length} 个对话。\n\n点击【确定】开始导出。\n(脚本将自动点击每个对话,请勿操作鼠标)`);
if (!confirmed) return;
State.isWorking = true; State.stopSignal = false; State.pauseSignal = false; State.abandonSignal = false;
UI.clearLog();
UI.updateUiState('working_batch');
for (let i = 0; i < allChats.length; i++) {
if (State.stopSignal) break;
// [MODIFIED] 增加暂停循环
while (State.pauseSignal && !State.stopSignal) {
await Core.wait(500); // 暂停时每半秒检查一次状态
}
if (State.stopSignal) break; // 从暂停中恢复后再次检查停止信号
const chat = allChats[i];
let safeTitle = chat.title;
UI.log(`\n▶️ [${i + 1}/${allChats.length}] 处理: ${safeTitle.substring(0, 15)}...`);
// [v13.12] 点击 导航,用旧节点消失 + 新节点出现确认切换成功
const isActive = chat.linkEl && chat.linkEl.classList.contains('is-active');
if (isActive || chat.el.classList.contains('selected')) {
UI.log(`ℹ️ 跳过切换 (当前已选中)...`);
} else {
const oldFirstNode = document.querySelector('user-query');
const clickTarget = chat.linkEl || chat.el;
clickTarget.click();
if (oldFirstNode) {
// 等旧 DOM 被移除 = 真正切换了
const waitStart = Date.now();
while (Date.now() - waitStart < CONFIG.batchPageLoad) {
if (!document.contains(oldFirstNode)) break;
await Core.wait(300);
}
}
// 等新内容出现
const contentStart = Date.now();
while (Date.now() - contentStart < CONFIG.batchPageLoad) {
if (document.querySelector('user-query')) break;
await Core.wait(300);
}
}
await Core.captureCurrentChat();
Core.download(safeTitle);
await Core.wait(1000);
}
State.isWorking = false;
State.stopSignal = false;
State.pauseSignal = false;
State.abandonSignal = false;
UI.updateUiState('idle');
if (!State.stopSignal && !State.abandonSignal) alert('🎉 队列备份完成!');
}
};
const UI = {
root: null, statusEl: null, logEl: null, batchBtn: null,
buttons: {}, // [MODIFIED] 统一管理按钮句柄
create: () => {
if (document.getElementById('gemini-backup-panel')) return;
const p = DOM.el('div', {
position: 'fixed', top: '80px', right: '20px', width: '8%',
background: '#131314', border: '1px solid #444', borderRadius: '12px',
zIndex: CONFIG.uiZIndex, padding: '16px', color: '#e3e3e3',
fontFamily: 'sans-serif', fontSize: '13px', boxShadow: '0 8px 30px rgba(0,0,0,0.6)'
});
p.id = 'gemini-backup-panel';
const h = DOM.el('div', { display: 'flex', justifyContent: 'space-between', marginBottom: '12px' });
h.appendChild(DOM.el('span', { fontWeight: 'bold', fontSize: '14px' }, 'Gemini 备份 v13.11'));
const close = DOM.el('span', { cursor: 'pointer', opacity: '0.7' }, '✕');
close.onclick = () => p.style.display = 'none';
h.appendChild(close);
p.appendChild(h);
const fDiv = DOM.el('div', { display: 'flex', gap: '5px', marginBottom: '10px' });
['md', 'json', 'txt'].forEach(fmt => {
const b = DOM.el('button', {
flex: 1, background: State.format === fmt ? '#a8c7fa' : '#333', border: 'none',
color: State.format === fmt ? '#000' : '#aaa', padding: '5px', borderRadius: '4px', cursor: 'pointer'
}, fmt.toUpperCase());
b.onclick = () => {
State.format = fmt;
Array.from(fDiv.children).forEach(c => { c.style.background = '#333'; c.style.color = '#aaa'; });
b.style.background = '#a8c7fa'; b.style.color = '#000';
};
fDiv.appendChild(b);
});
p.appendChild(fDiv);
const createInput = (label, val, onChange) => {
const d = DOM.el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px', fontSize: '11px', color: '#aaa' });
const l = DOM.el('span', {}, label);
const i = DOM.el('input', {
width: '50px', background: '#222', border: '1px solid #444', color: '#fff',
borderRadius: '4px', padding: '2px 4px', textAlign: 'center'
});
i.type = 'number'; i.value = val; i.onchange = onChange;
d.appendChild(l); d.appendChild(i);
return d;
};
p.appendChild(createInput('滚动延迟(ms):', State.scrollDelay, (e) => {
State.scrollDelay = Math.max(50, parseInt(e.target.value));
UI.log(`⚙️ 滚动延迟: ${State.scrollDelay}ms`);
}));
p.appendChild(createInput('历史延迟(ms):', State.historyDelay, (e) => {
State.historyDelay = Math.max(500, parseInt(e.target.value));
UI.log(`⚙️ 历史延迟: ${State.historyDelay}ms`);
}));
const b1 = DOM.el('button', {
width: '100%', padding: '10px', marginTop: '10px', marginBottom: '8px', background: '#444746',
color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600'
}, '📜 导出当前对话');
b1.onclick = async () => {
if (State.isWorking) return;
State.isWorking = true; State.stopSignal = false; State.abandonSignal = false;
UI.clearLog();
UI.updateUiState('working_single');
await Core.captureCurrentChat();
Core.download();
State.isWorking = false; State.stopSignal = false; State.abandonSignal = false;
UI.updateUiState('idle');
}; p.appendChild(b1);
UI.buttons.exportCurrent = b1;
const bScan = DOM.el('button', {
width: '100%', padding: '10px', marginBottom: '8px', background: '#333', border: '1px dashed #666',
color: '#fff', borderRadius: '6px', cursor: 'pointer', fontWeight: '600'
}, '🔍 扫描/累积 (先滚再点)');
bScan.onclick = Core.accumulateScan;
p.appendChild(bScan);
UI.buttons.scan = bScan;
const bBatch = DOM.el('button', {
width: '100%', padding: '10px', marginBottom: '8px', background: '#a8c7fa',
color: '#062e6f', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600'
}, '🗂️ 导出队列中所有对话');
bBatch.onclick = Core.runBatchFromQueue;
p.appendChild(bBatch);
UI.batchBtn = bBatch;
UI.buttons.exportBatch = bBatch;
// [MODIFIED] 新增暂停按钮
const bPause = DOM.el('button', {
width: '100%', padding: '10px', marginBottom: '8px', background: '#3c78d8',
color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', display: 'none'
}, '⏸️ 暂停');
bPause.onclick = () => {
State.pauseSignal = !State.pauseSignal;
if (State.pauseSignal) {
UI.log('⏸️ 已暂停,等待继续...');
UI.updateUiState('paused');
} else {
UI.log('▶️ 继续执行...');
UI.updateUiState('working_batch');
}
};
p.appendChild(bPause);
UI.buttons.pause = bPause;
const bSaveNow = DOM.el('button', {
width: '100%', padding: '10px', marginBottom: '8px', background: '#ff9800',
color: '#000', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', display: 'none'
}, '⚡ 立即停止并导出');
bSaveNow.onclick = () => {
if (State.isWorking) {
UI.log('⚡ 用户请求中断,将在当前任务完成后停止...');
State.stopSignal = true;
}
};
p.appendChild(bSaveNow);
UI.buttons.stopAndSave = bSaveNow;
const bStop = DOM.el('button', {
width: '100%', padding: '10px', marginBottom: '8px', background: '#f28b82',
color: '#601410', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', display: 'none'
}, '⏹ 放弃任务');
bStop.onclick = () => {
State.stopSignal = true;
State.abandonSignal = true; // [v13.11] 放弃标志,阻止 download
State.collectedData.clear(); // 放弃任务时清空已收集的数据
UI.log('🛑 正在放弃,数据已清空...');
};
p.appendChild(bStop);
UI.buttons.abandon = bStop;
const logBox = DOM.el('div', {
height: '100px', overflowY: 'auto', background: '#000', border: '1px solid #333',
marginTop: '10px', fontSize: '11px', fontFamily: 'monospace', padding: '8px',
color: '#0f0', whiteSpace: 'pre-wrap', borderRadius: '4px'
});
logBox.id = 'gemini-log-box';
logBox.innerText = '>> v13.11 修复队列与放弃逻辑版。\n>> 队列改用链接导航+等待内容加载。\n';
p.appendChild(logBox);
UI.logEl = logBox;
UI.statusEl = DOM.el('div', { textAlign: 'center', color: '#8e918f', fontSize: '12px', marginTop: '5px' }, 'Alt+Q 隐藏/显示');
p.appendChild(UI.statusEl);
document.body.appendChild(p);
UI.root = p;
},
// [MODIFIED] 采用专业的"环形缓冲区"模式重构日志函数,并优化滚动逻辑
log: (msg) => {
if (!UI.logEl) return;
const MAX_LOG_LINES = 200; // 定义日志区域最多保留的DOM节点数
// 1. 创建新的日志行元素
const time = new Date().toLocaleTimeString();
const logLine = document.createElement('div');
logLine.textContent = `[${time}] ${msg}`;
// 2. 将新日志行追加到末尾
UI.logEl.appendChild(logLine);
// 3. 检查并移除超出的旧日志行(维护缓冲区大小)
while (UI.logEl.childElementCount > MAX_LOG_LINES) {
UI.logEl.removeChild(UI.logEl.firstChild);
}
// 4. 平滑滚动到底部
// 使用 requestAnimationFrame 可以确保滚动操作在下一次浏览器绘制前执行,更平滑
requestAnimationFrame(() => {
UI.logEl.scrollTop = UI.logEl.scrollHeight;
});
},
updateBatchBtn: () => {
if (UI.batchBtn) UI.batchBtn.innerText = `🗂️ 导出队列 (${State.sidebarQueue.size})`;
},
clearLog: () => { if (UI.logEl) UI.logEl.innerText = '>> 开始任务...\n'; },
// [MODIFIED] 统一UI状态管理器
updateUiState: (state) => {
const { exportCurrent, scan, exportBatch, pause, stopAndSave, abandon } = UI.buttons;
// 默认隐藏所有控制按钮
Object.values(UI.buttons).forEach(btn => btn.style.display = 'none');
switch (state) {
case 'working_single':
stopAndSave.innerText = '⚡ 立即停止并导出';
stopAndSave.style.display = 'block';
abandon.style.display = 'block';
break;
case 'working_batch':
pause.innerText = '⏸️ 暂停';
stopAndSave.innerText = '🛑 停止后续任务';
pause.style.display = 'block';
stopAndSave.style.display = 'block';
abandon.style.display = 'block';
break;
case 'paused':
pause.innerText = '▶️ 继续';
stopAndSave.innerText = '🛑 停止后续任务';
pause.style.display = 'block';
stopAndSave.style.display = 'block';
abandon.style.display = 'block';
break;
case 'idle':
default:
exportCurrent.style.display = 'block';
scan.style.display = 'block';
exportBatch.style.display = 'block';
if (state === 'idle') UI.log('>> 任务结束。');
break;
}
},
};
const init = () => {
UI.create();
document.addEventListener('keydown', e => {
if (e.altKey && e.code === 'KeyQ') {
const p = document.getElementById('gemini-backup-panel');
if (!p) UI.create();
else p.style.display = p.style.display === 'none' ? 'block' : 'none';
}
});
new MutationObserver(() => { if (!document.getElementById('gemini-backup-panel')) UI.create(); })
.observe(document.body, { childList: true, subtree: true });
};
setTimeout(init, 2000);
})();