一键导入 Pixiv 图片/动图到 Eagle;支持详情/列表/勾选三种模式;实时进度/ETA/可取消;面板可拖拽并记忆位置;本地或 Eagle 模式切换;作者文件夹自动归档。
// ==UserScript== // @name PTE Pixiv→Eagle 标签管理 // @name:en PTE Pixiv→Eagle Tag Manager // @author Mliechoy // @version 1.1 // @description 一键导入 Pixiv 图片/动图到 Eagle;支持详情/列表/勾选三种模式;实时进度/ETA/可取消;面板可拖拽并记忆位置;本地或 Eagle 模式切换;作者文件夹自动归档。 // @description:en One-click import Pixiv to Eagle (ugoira→GIF); detail/list/selected modes; progress & ETA; cancel; draggable panel with position memory; local only. // @description:ja Pixiv を Eagle にワンクリックで取り込み(ugoira→GIF 含む);詳細/一覧/選択の取り込み;進捗・ETA・キャンセル;ドラッグ可能&位置記憶のパネル;ローカル通信。 // @description:zh-TW 一鍵匯入 Pixiv 至 Eagle(含 ugoira→GIF);支援詳情/列表/勾選;進度列/ETA/可取消;面板可拖曳並記憶位置;僅本機通訊。 // @match https://www.pixiv.net/* // @grant GM_xmlhttpRequest // @grant GM_download // @connect localhost // @connect 127.0.0.1 // @connect i.pximg.net // @connect cdn.jsdelivr.net // @run-at document-idle // @license MIT // @homepage https://github.com/Mlietial/Save-Pixiv-picture-to-eagle // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/gif.min.js // @require https://unpkg.com/pinyin-pro // @namespace https://pte-script.example // ==/UserScript== (function () { 'use strict'; /******************** 常量 & 工具 ********************/ const BIG_GIF_LIMIT = 40 * 1024 * 1024; // 约 40MB:ugoira→GIF 体积超过此值时优先切换为本地模式 const EAGLE = { base: 'http://localhost:41595', api: { add: '/api/item/addFromURLs', list: '/api/folder/list', create: '/api/folder/create', update: '/api/folder/update' } }; const QWEN_API = { endpoints: ['http://localhost:11434/v1/chat/completions'], model: 'qwen2.5:14b', timeout: 15000 }; const LSKEY = 'pxeMini'; const LS = { get(k, d) { try { const val = localStorage.getItem(LSKEY + ':' + k); if (val === null) return d; // JSON解析 try { return JSON.parse(val); } catch { // 失败返回原值 return val; } } catch { return d; } }, set(k, v) { try { localStorage.setItem(LSKEY + ':' + k, typeof v === 'string' ? v : JSON.stringify(v)); } catch { } } }; const sanitize = s => (s || '').replace(/[\r\n]+/g, ' ').replace(/[\/\\:*?"<>|]/g, '_').trim(); const lower = s => (s || '').toLowerCase(); // 拼音匹配函数 const pinyinMatch = (text, query) => { if (!query) return true; const queryLower = query.toLowerCase(); // 直接匹配中文 if (text.toLowerCase().includes(queryLower)) { return true; } // pinyin-pro 库匹配 try { if (typeof window !== 'undefined') { let pinyinLib = null; if (window.pinyinPro && typeof window.pinyinPro.pinyin === 'function') { pinyinLib = window.pinyinPro; } else if (window.pinyin && typeof window.pinyin.pinyin === 'function') { pinyinLib = window.pinyin; } if (pinyinLib) { const pinyinArray = pinyinLib.pinyin(text, { toneType: 'none', type: 'array' }); if (Array.isArray(pinyinArray) && pinyinArray.length > 0) { let fullPinyin = ''; let firstLetters = ''; for (const p of pinyinArray) { if (p && p.length > 0) { fullPinyin += p; firstLetters += p[0]; } } if (fullPinyin.includes(queryLower) || firstLetters.includes(queryLower)) { return true; } } } } } catch (e) { // 错误时返回 false } return false; }; const sleep = ms => new Promise(r => setTimeout(r, ms)); /******************** Qwen 本地翻译函数 ********************/ async function translateWithQwen(text, targetLang = 'zh') { // 缓存避免重复调用 const cacheKey = `trans_${lower(text)}_${targetLang}`; const cached = LS.get(cacheKey); if (cached) return cached; try { const prompt = targetLang === 'zh' ? `将以下内容翻译成中文,只回复翻译结果,不要加任何前缀或解释:${text}` : `Translate the following text to ${targetLang}. Only reply with the translation result, no prefix or explanation: ${text}`; // 向 Ollama 请求 return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: QWEN_API.endpoints[0], headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ model: QWEN_API.model, messages: [{ role: 'user', content: prompt }], max_tokens: 200, temperature: 0.3, stream: false }), timeout: QWEN_API.timeout, onload: (res) => { try { if (res.status === 200) { const data = JSON.parse(res.responseText); const result = data.choices?.[0]?.message?.content?.trim() || ''; if (result && result.length > 0) { LS.set(cacheKey, result); resolve(result); return; } } resolve(text); // 失败时返回原文 } catch (e) { console.warn('[Qwen 翻译] 响应解析失败:', e); resolve(text); } }, onerror: () => { console.warn(`[Qwen 翻译] 连接失败,请确保 Ollama 正在运行:ollama run qwen2.5:14b`); resolve(text); }, ontimeout: () => { console.warn(`[Qwen 翻译] 请求超时(${QWEN_API.timeout}ms),模型响应较慢`); resolve(text); } }); }); } catch (e) { console.warn('[Qwen 翻译] 错误:', e); return text; } } /******************** 运行参数 ********************/ const CFG = { filters: { bookmarkMin: 0, excludeTags: LS.get('excludeTags', ''), pageRange: '' }, ui: { x: 24, y: 24, margin: 16 }, feature: { useUploadAsAddDate: !!LS.get('useUploadAsAddDate', false), translateTags: !!LS.get('translateTags', false) }, mode: LS.get('mode', 'eagle') }; /******************** Eagle API ********************/ function xhr({ url, method = 'GET', data = null, timeout = 30000, raw = false }) { // 统一请求处理 return new Promise((resolve, reject) => { GM_xmlhttpRequest({ url, method, // raw=true 时 data 为字符串 data: data ? (raw ? data : JSON.stringify(data)) : null, headers: { 'Content-Type': 'application/json' }, timeout, onload: (res) => { try { resolve(JSON.parse(res.responseText || '{}')); } catch { resolve({}); } }, onerror: () => reject(new Error('Eagle连接失败')), ontimeout: () => reject(new Error('Eagle请求超时')) }); }); } async function listFolders() { const r = await xhr({ url: EAGLE.base + EAGLE.api.list }); return (r && r.data) || r.folders || []; } async function createFolder(name, parentId) { const payload = parentId ? { folderName: name, parent: parentId } : { folderName: name, isRoot: true }; const r = await xhr({ url: EAGLE.base + EAGLE.api.create, method: 'POST', data: payload }); return r?.data?.id || r?.id || r?.folderId; } async function updateFolderDesc(id, desc) { await xhr({ url: EAGLE.base + EAGLE.api.update, method: 'POST', data: { folderId: id, newDescription: desc, description: desc } }); } function flattenFolders(tree) { const out = []; const st = [...(Array.isArray(tree) ? tree : [tree])].filter(Boolean); while (st.length) { const f = st.shift(); out.push(f); if (f.children && f.children.length) st.push(...f.children); } return out; } async function addToEagle(items, folderId) { // 体积限制由浏览器处理 const payload = { items, folderId }; const json = JSON.stringify(payload); return await xhr({ url: EAGLE.base + EAGLE.api.add, method: 'POST', data: json, raw: true }); } /******************** Toast 提示 ********************/ function showToast(message, duration = 3000) { const id = 'pte-toast-' + Date.now(); const toast = document.createElement('div'); toast.id = id; Object.assign(toast.style, { position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0, 0, 0, 0.85)', color: '#fff', padding: '12px 20px', borderRadius: '8px', fontSize: '14px', zIndex: 2147483648, maxWidth: '80vw', wordBreak: 'break-word', whiteSpace: 'pre-wrap', lineHeight: '1.5', animation: 'pte-toast-in 0.3s ease-out', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)' }); toast.textContent = message; if (!document.getElementById('pte-toast-style')) { const style = document.createElement('style'); style.id = 'pte-toast-style'; style.textContent = ` @keyframes pte-toast-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } @keyframes pte-toast-out { from { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 0; transform: translateX(-50%) translateY(20px); } } `; document.head.appendChild(style); } document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'pte-toast-out 0.3s ease-in forwards'; setTimeout(() => toast.remove(), 300); }, duration); } /******************** 页面工具 & 统一 Fetch 包装 ********************/ const aborters = new Set(); function cancelInflight() { for (const a of aborters) { try { a.abort(); } catch { } } aborters.clear(); } // fetch 统一包装 async function fetchUrl(url, options = {}) { const { type = 'json', credentials = 'include', signal = null } = options; const ctrl = signal ? null : new AbortController(); const sig = signal || ctrl?.signal; if (ctrl) aborters.add(ctrl); try { const res = await fetch(url, { credentials, signal: sig }); if (type === 'json') return await res.json(); if (type === 'text') return await res.text(); if (type === 'arrayBuffer') return await res.arrayBuffer(); return res; } finally { if (ctrl) aborters.delete(ctrl); } } // 向后兼容的快捷函数 async function getJSON(url) { return fetchUrl(url, { type: 'json' }); } async function getTEXT(url) { return fetchUrl(url, { type: 'text' }); } function isUser() { return /\/users\/\d+/.test(location.pathname); } function isArtwork() { return /\/artworks\/\d+/.test(location.pathname); } async function allIllustIds(uid) { const r = await getJSON(`https://www.pixiv.net/ajax/user/${uid}/profile/all`); const ill = r.body?.illusts ? Object.keys(r.body.illusts) : []; const man = r.body?.manga ? Object.keys(r.body.manga) : []; return [...new Set([...ill, ...man])]; } function ogTitle(html) { const m = html.match(/<meta[^>]+property=['"]og:title['"][^>]*content=['"]([^'"]+)['\"]/i); return m ? sanitize(m[1]) : ''; } async function illustInfoAndPages(id) { // 自动重试避免占位信息 const tryFetch = async () => { const info = await getJSON(`https://www.pixiv.net/ajax/illust/${id}`); const pages = await getJSON(`https://www.pixiv.net/ajax/illust/${id}/pages`); const b = info.body || {}; const pageUrls = (pages.body || []).map(p => p.urls?.original).filter(Boolean); const tagList = Array.isArray(b.tags?.tags) ? b.tags.tags : []; const tags = tagList.map(t => t?.tag || t?.translation?.en || t?.translation?.ja || '').filter(Boolean); return { title: sanitize(b.title || `pixiv_${id}`), tags, pageUrls, userId: b.userId, userName: sanitize(b.userName || b.userAccount || ''), illustType: b.illustType, bookmarkCount: b.bookmarkCount || 0, uploadDate: b.uploadDate }; }; let meta = await tryFetch(); // 如遇到标签为空 / 标题为占位 / 用户信息缺失,进行最多两次补救性重试 if ((!meta.tags?.length) || /^pixiv_\d+$/.test(meta.title) || !meta.userId || !meta.userName) { for (let i = 0; i < 2; i++) { await sleep(300 + i * 300); const nx = await tryFetch(); if ((!meta.tags?.length) && nx.tags?.length) meta.tags = nx.tags; if (/^pixiv_\d+$/.test(meta.title) && !/^pixiv_\d+$/.test(nx.title)) meta.title = nx.title; if (!meta.uploadDate && nx.uploadDate) meta.uploadDate = nx.uploadDate; if (!meta.userId && nx.userId) meta.userId = nx.userId; if (!meta.userName && nx.userName) meta.userName = nx.userName; } // 仍然是占位标题时,最后尝试从网页 og:title 中兜底一次 if (/^pixiv_\d+$/.test(meta.title)) { try { const html = await getTEXT(`https://www.pixiv.net/artworks/${id}`); const og = ogTitle(html); if (og) meta.title = og; } catch { } } } // 标签中添加作者名 if (!meta.tags?.length) { meta.tags = meta.userName ? [meta.userName] : []; } else { meta.tags = Array.from(new Set([meta.userName, ...meta.tags].filter(Boolean))); } return meta; } async function ugoiraMeta(id) { return await getJSON(`https://www.pixiv.net/ajax/illust/${id}/ugoira_meta`); } function parseRange(str) { if (!str) return null; const s = str.trim(); if (!s) return null; const a = s.match(/^(\d+)-(\d+)$/); if (a) { const x = +a[1], y = +a[2]; if (x > 0 && y >= x) return [x, y]; } const b = s.match(/^(\d+)$/); if (b) { const n = +b[1]; if (n > 0) return [n, n]; } return null; } /******************** Welcome Modal ********************/ function createWelcomeModal(updatedAtTs) { if (document.getElementById('pteWelcome')) return; // 从脚本 metadata 动态读取版本号 let PTE_VER = '1.1'; let PTE_UPDATED_DATE = '2025-11-20'; try { if (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) { PTE_VER = GM_info.script.version; } else if (document.currentScript && document.currentScript.textContent) { const match = /@version\s+([0-9.]+)/i.exec(document.currentScript.textContent); if (match) PTE_VER = match[1]; } if (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.installed) { PTE_UPDATED_DATE = new Date(GM_info.script.installed).toISOString().split('T')[0]; } } catch (e) { // 使用默认值 } var mask = document.createElement('div'); mask.id = 'pteWelcome'; Object.assign(mask.style, { position: 'fixed', inset: '0', background: 'rgba(0,0,0,.35)', backdropFilter: 'blur(2px)', zIndex: 2147483647, display: 'flex', alignItems: 'center', justifyContent: 'center' }); var box = document.createElement('div'); Object.assign(box.style, { width: 'min(560px,92vw)', borderRadius: '16px', background: '#fff', boxShadow: '0 12px 40px rgba(0,0,0,.18)', padding: '16px 18px', fontSize: '13px', color: '#444', lineHeight: '1.6', maxHeight: '80vh', overflow: 'auto' }); var timeStr = PTE_UPDATED_DATE; box.innerHTML = '' + '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">' + '<div style="font-size:18px;font-weight:700;color:#1f6fff;">PTE 已更新 ✅</div>' + '<span style="margin-left:auto;color:#999;font-size:12px">v' + PTE_VER + '</span>' + '</div>' + '<div style="color:#999;font-size:12px;margin-bottom:8px;">更新时间:' + timeStr + ' | 版本号:v' + PTE_VER + '</div>' + '<div>' + '<p>右上角工具条:<b style="color:#409eff">E(蓝)</b> = Eagle 模式,<b style="color:#f1a72e">D(橙)</b> = 本地模式。</p>' + '<p>详情页六键:<code>此作</code> / <code>本页</code> / <code>仅勾选</code> / <code>全选</code> / <code>全不选</code> / <code>下一页</code>。</p><p>顶部工具条新增并固定"🕒 投稿时间→添加日期"开关(点击切换;关闭时灰度显示)。</p>' + '<p>第二页:🔁 反选 · 📁 选择下载目录(左下) · 📜 公告 · ⬅️ 上一页(右下)。</p>' + '<p><b style="color:#ff4d4f">大动图说明:</b> 当 ugoira→GIF 体积过大(约 >40MB)时,脚本会自动从 Eagle 模式切换为"保存到本地"模式,并保存到下载目录下的 <code>Pixiv/作者名_作者ID/作品ID.gif</code>,以避免浏览器 / 油猴在导入 Eagle 时因消息过长而卡住。</p>' + '<p style="color:#666">小技巧:点击绿灯检查 Eagle;点"➖"可缩小为悬浮圆点。</p>' + '<p style="margin-top:6px"><b>没看到弹窗/工具条?</b> 如果脚本已启动但首次没看到,UI 可能在浏览器窗口右侧;请尝试将浏览器窗口<b>拉宽</b>即可看见。</p>' + '<p><b>连续多选:</b> 在列表/缩略图页,先点击左侧的勾选框选中一项,然后按住 <kbd>Shift</kbd> 再点击另一项,<b>两者之间的范围</b>会被一次性选中。</p>' + '</div>' + '<div style="display:flex;gap:10px;margin-top:14px;justify-content:flex-end;">' + '<button id="pxeWelcomeOk" style="padding:6px 14px;border:none;border-radius:8px;background:#409eff;color:#fff;cursor:pointer;font-weight:600">我知道了</button>' + '</div>'; mask.appendChild(box); document.body.appendChild(mask); mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); var ok = box.querySelector('#pxeWelcomeOk'); if (ok) ok.addEventListener('click', function () { mask.remove(); }); } /******************** 操作历史记录函数 ********************/ function addOperationLog(action, details) { try { let logs = LS.get('operationLogs', []); if (typeof logs === 'string') { try { logs = JSON.parse(logs); } catch { logs = []; } } if (!Array.isArray(logs)) logs = []; const now = new Date(); const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); const logEntry = { action: action, details: details, timestamp: timeStr, date: now.toISOString().split('T')[0] }; logs.push(logEntry); // Debug 日志:操作记录 debugLog('LOG', '添加操作日志', { action, details, timestamp: timeStr }); // 在localStorage中保持最近20条记录(防止数据无限增长) if (logs.length > 20) { logs = logs.slice(-20); } LS.set('operationLogs', logs); // 更新历史显示 const historyDiv = document.getElementById('pteOperationHistory'); if (historyDiv) { updateOperationHistory(); } } catch(e) { // silent } } // 全局变量 - 标签管理(最简单直接的方式) var savedTags = (function() { let data = LS.get('tagTranslations', {}); if (typeof data === 'string') { try { data = JSON.parse(data); } catch { data = {}; } } return (data && typeof data === 'object') ? data : {}; })(); function updateOperationHistory() { try { const logs = LS.get('operationLogs', []); if (typeof logs === 'string') { try { var parsedLogs = JSON.parse(logs); } catch { parsedLogs = []; } } else { parsedLogs = Array.isArray(logs) ? logs : []; } const historyDiv = document.getElementById('pteOperationHistory'); if (!historyDiv) return; if (parsedLogs.length === 0) { historyDiv.innerHTML = '<div style="color:#999;">暂无操作记录</div>'; return; } let html = ''; // 显示最近的2条记录 parsedLogs.slice(-2).reverse().forEach(log => { html += `<div style="font-size:11px;color:#333;">${log.action}${log.details ? ' - ' + log.details : ''} <span style="color:#999;margin-left:8px;">${log.timestamp}</span></div>`; }); historyDiv.innerHTML = html; } catch(e) { // silent } } /******************** 统一标签管理弹窗 ********************/ // Debug 日志函数 const debugLog = (category, message, data = null) => { const settings = LS.get('tagManagerSettings', { debugMode: false }); if (settings.debugMode) { const timestamp = new Date().toLocaleTimeString('zh-CN'); console.log(`[${timestamp}] [PTE-${category}] ${message}`, data || ''); } }; async function createTagManagerModal() { if (document.getElementById('pteTagManager')) return; const mask = document.createElement('div'); mask.id = 'pteTagManager'; Object.assign(mask.style, { position: 'fixed', inset: '0', background: 'transparent', backdropFilter: 'none', zIndex: 2147483647, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }); const box = document.createElement('div'); Object.assign(box.style, { width: 'min(1200px,95vw)', borderRadius: '16px', background: '#fff', boxShadow: '0 12px 40px rgba(0,0,0,.18)', padding: '20px', fontSize: '13px', color: '#444', lineHeight: '1.6', maxHeight: '90vh', overflow: 'auto' }); // 直接从 localStorage 读取最新的排除标签 const excludedTags = LS.get('excludeTags', '') || CFG.filters.excludeTags || ''; box.innerHTML = ` <div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;border-bottom:2px solid #409eff;padding-bottom:12px;"> <div style="font-size:18px;font-weight:700;color:#1f6fff;">🏷️ 标签管理中心</div> <span style="margin-left:auto;color:#666;font-size:12px;">已保存翻译: ${Object.keys(savedTags).length} | 已排除: ${excludedTags.split(',').filter(Boolean).length}</span> </div> <!-- 操作历史 --> <div style="background:#e8f4f8;border-left:4px solid #00bcd4;padding:12px;border-radius:4px;margin-bottom:8px;"> <div style="font-weight:600;color:#00695c;margin-bottom:4px;font-size:12px;">📋 最近操作</div> <div id="pteOperationHistory" style="font-size:11px;color:#00695c;max-height:50px;overflow-y:auto;line-height:1.6;"> <div style="color:#999;">暂无操作记录</div> </div> </div> <!-- 三列布局 --> <div style="display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:12px;height:400px;"> <!-- 左列:排除标签 --> <div style="display:flex;flex-direction:column;border:1px solid #e0e0e0;border-radius:8px;padding:12px;background:#fafafa;"> <div style="font-weight:600;color:#f57c00;margin-bottom:8px;display:flex;align-items:center;gap:6px;justify-content:space-between;"> <div style="display:flex;align-items:center;gap:6px;"> <span>🚫 排除标签</span> <span style="font-size:11px;color:#999;font-weight:400;" id="pteExcludedCount">(0)</span> </div> <div style="display:flex;gap:4px;"> <input id="pteManualExcludeInput" type="text" placeholder="输入标签" style="width:80px;padding:4px 6px;border:1px solid #d9d9d9;border-radius:3px;font-size:10px;box-sizing:border-box;" /> <button id="pteManualExcludeAdd" style="width:24px;height:24px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:10px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:center;">+</button> <div style="position:relative;"> <button id="pteExcludeSort" style="width:24px;height:24px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:10px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:center;" title="点击切换排序方式">↑</button> <div id="pteSortMenu" style="display:none;position:absolute;top:100%;right:-8px;margin-top:2px;background:#fff;border:1px solid #d9d9d9;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:10000;width:fit-content;"> <div data-sort="alpha-asc" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="字母升序">A→Z</div> <div data-sort="alpha-desc" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="字母降序">Z→A</div> <div data-sort="time-new" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="最新添加优先">新→旧</div> <div data-sort="time-old" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;white-space:nowrap;" title="最早添加优先">旧→新</div> </div> </div> </div> </div> <div id="pteExcludeList" style="flex:0 0 auto;overflow-y:auto;max-height:233px;min-height:250px;margin-bottom:6px;padding:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff;display:flex;flex-direction:column;gap:6px;"></div> <div style="display:flex;gap:6px;margin-bottom:4px;"> <button id="pteExcludeImport" style="flex:1;padding:10px;border:none;border-radius:6px;background:#409eff;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">📥 导入</button> <button id="pteExcludeExport" style="flex:1;padding:10px;border:none;border-radius:6px;background:#67c23a;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">📤 导出</button> </div> <div style="display:flex;gap:6px;margin-bottom:0px;"> <button id="pteExcludeSave" style="flex:1;padding:10px;border:none;border-radius:6px;background:#ff9800;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">💾 保存</button> <button id="pteExcludeReset" style="flex:1;padding:10px;border:1px solid #f56c6c;border-radius:6px;background:#fff;color:#f56c6c;cursor:pointer;font-weight:600;font-size:12px;">🗑️ 清空</button> </div> </div> <!-- 中列:翻译区域 --> <div style="display:flex;flex-direction:column;border:1px solid #e0e0e0;border-radius:8px;padding:12px;background:#fafafa;min-height:0;"> <!-- 上半部分:输入和结果 --> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;height:calc(100% - 80px);margin-bottom:6px;"> <!-- 输入 --> <div style="display:flex;flex-direction:column;min-height:0;overflow:hidden;height:100%;"> <div style="font-weight:600;color:#1976d2;margin-bottom:6px;font-size:12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;"> <span>📝 待翻译</span> <div style="display:flex;gap:4px;align-items:center;"> <input id="pteManualTransInput" type="text" placeholder="输入标签" style="width:80px;padding:4px 6px;border:1px solid #d9d9d9;border-radius:3px;font-size:10px;box-sizing:border-box;" /> <button id="pteManualTransAdd" style="width:24px;height:24px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:10px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:center;">+</button> <button id="pteQuickExclude" style="width:36px;height:24px;border:none;border-radius:3px;background:#f56c6c;color:#fff;cursor:pointer;font-size:10px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:center;" title="将待翻译中的标签移到排除列表">排除</button> </div> </div> <div id="pteTransInputList" style="flex:1;overflow-y:auto;padding:8px;border:1px solid #d9d9d9;border-radius:6px;background:#f9f9f9;display:flex;flex-direction:column;gap:6px;min-height:0;"> <div style="color:#999;text-align:center;padding:30px 10px;font-size:12px;">暂无待翻译标签</div> </div> <textarea id="pteTransInput" style="display:none;flex:1;padding:8px;border:1px solid #d9d9d9;border-radius:6px;font-family:monospace;font-size:11px;resize:none;box-sizing:border-box;max-height:400px;overflow-y:auto;" placeholder="每行一个"></textarea> </div> <!-- 结果 --> <div style="display:flex;flex-direction:column;min-height:0;overflow:hidden;height:100%;"> <div style="font-weight:600;color:#1976d2;margin-bottom:6px;font-size:12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;"> <span>✏️ 翻译结果</span> <button id="pteClearTransResult" title="标签管理设置" style="width:24px;height:24px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:12px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:center;">⚙️</button> </div> <div id="pteTransResult" style="flex:1;border:1px solid #d9d9d9;border-radius:6px;padding:8px;background:#f9f9f9;overflow-y:auto;font-size:11px;min-height:0;"> <div style="color:#999;text-align:center;padding:30px 10px;">等待翻译...</div> </div> </div> </div> <!-- 操作按钮 --> <div style="display:flex;flex-direction:column;gap:4px;margin-top:3px;"> <div style="display:flex;gap:6px;"> <button id="pteExtractTags" style="flex:1;padding:10px;border:none;border-radius:6px;background:#ff9800;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">📋 提取标签</button> <button id="pteTranslateAll" style="flex:1;padding:10px;border:none;border-radius:6px;background:#409eff;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">🚀 翻译</button> </div> <div style="display:flex;gap:6px;"> <button id="pteListExport" style="flex:0.44;padding:10px;border:none;border-radius:6px;background:#67c23a;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">导出列表</button> <button id="pteListImport" style="flex:0.44;padding:10px;border:none;border-radius:6px;background:#409eff;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">导入列表</button> <button id="pteSaveAll" style="flex:1;padding:10px;border:none;border-radius:6px;background:#67c23a;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">💾 保存全部</button> </div> </div> </div> <!-- 右列:已保存和工具 --> <div style="display:flex;flex-direction:column;border:1px solid #e0e0e0;border-radius:8px;padding:12px;background:#fafafa;flex:0 0 auto;"> <div style="font-weight:600;color:#388e3c;margin-bottom:8px;display:flex;align-items:center;gap:6px;justify-content:space-between;"> <div style="display:flex;align-items:center;gap:6px;"> <span>📌 已保存翻译</span> <span style="font-size:11px;color:#999;font-weight:400;" id="pteSavedCount">(0)</span> </div> <div style="display:flex;gap:4px;align-items:center;"> <input id="pteSavedSearch" type="text" placeholder="搜索翻译" style="width:80px;padding:4px 6px;border:1px solid #d9d9d9;border-radius:3px;font-size:10px;box-sizing:border-box;" /> <div style="position:relative;"> <button id="pteSavedSort" style="width:24px;height:24px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:10px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:center;" title="点击切换排序方式">↑</button> <div id="pteSavedSortMenu" style="display:none;position:absolute;top:100%;right:-8px;margin-top:2px;background:#fff;border:1px solid #d9d9d9;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:10000;width:fit-content;"> <div data-sort="tag-asc" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="原始标签升序">标A→Z</div> <div data-sort="tag-desc" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="原始标签降序">标Z→A</div> <div data-sort="trans-asc" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="翻译升序">译A→Z</div> <div data-sort="trans-desc" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="翻译降序">译Z→A</div> <div data-sort="time-new" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;border-bottom:1px solid #f0f0f0;white-space:nowrap;" title="最新添加优先">新→旧</div> <div data-sort="time-old" style="padding:8px 12px;cursor:pointer;font-size:11px;color:#333;white-space:nowrap;" title="最早添加优先">旧→新</div> </div> </div> </div> </div> <div id="pteSavedList" style="flex:0 0 auto;overflow-y:auto;max-height:233px;min-height:250px;margin-bottom:8px;padding:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff;display:flex;flex-direction:column;gap:6px;"> </div> <div style="display:flex;gap:6px;flex-direction:column;margin-bottom:0px;"> <div style="display:flex;gap:6px;"> <button id="pteSavedImport" style="flex:1;padding:10px;border:none;border-radius:6px;background:#409eff;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">📥 导入</button> <button id="pteSavedExport" style="flex:1;padding:10px;border:none;border-radius:6px;background:#67c23a;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">📤 导出</button> </div> <div style="display:flex;gap:6px;"> <button id="pteSavedSave" style="flex:1;padding:10px;border:none;border-radius:6px;background:#ff9800;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">💾 保存</button> <button id="pteSavedReset" style="flex:1;padding:10px;border:1px solid #f56c6c;border-radius:6px;background:#fff;color:#f56c6c;cursor:pointer;font-weight:600;font-size:12px;">🗑️ 清空</button> </div> </div> </div> </div> <!-- 隐藏的文件输入框 --> <input id="pteFileImportInput" type="file" style="display:none;" accept=".txt,.csv"> <!-- 导入选择窗口 --> <div id="pteImportDialog" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border:2px solid #409eff;border-radius:8px;padding:20px;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,0.2);min-width:400px;max-width:600px;"> <div style="font-weight:600;color:#1f6fff;margin-bottom:16px;font-size:14px;">📥 导入数据</div> <textarea id="pteImportTextarea" placeholder="在此粘贴导入内容..." style="width:100%;height:200px;padding:10px;border:1px solid #d9d9d9;border-radius:6px;font-family:monospace;font-size:12px;resize:none;box-sizing:border-box;"></textarea> <div style="display:flex;gap:10px;margin-top:16px;justify-content:flex-end;"> <button id="pteImportCancel" style="padding:8px 16px;border:1px solid #d9d9d9;border-radius:6px;background:#f5f5f5;color:#666;cursor:pointer;font-weight:600;font-size:12px;">取消</button> <button id="pteImportConfirm" style="padding:8px 16px;border:none;border-radius:6px;background:#409eff;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">导入</button> </div> </div> <!-- 导入对话框背景遮罩 --> <div id="pteImportMask" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:9998;"></div> <div style="display:flex;gap:10px;justify-content:flex-end;margin-top:15px;padding-top:4px;"> <button id="pteManagerClose" style="padding:8px 16px;border:1px solid #d9d9d9;border-radius:8px;background:#fff;color:#666;cursor:pointer;font-weight:600;">关闭</button> </div> `; mask.appendChild(box); document.body.appendChild(mask); // 初始化"保存全部"按钮状态(默认禁用) const saveAllBtn = box.querySelector('#pteSaveAll'); saveAllBtn.disabled = true; saveAllBtn.style.opacity = '0.5'; saveAllBtn.style.cursor = 'not-allowed'; // 显示已保存的翻译 const updateSavedList = () => { const savedListEl = box.querySelector('#pteSavedList'); const countEl = box.querySelector('#pteSavedCount'); savedListEl.innerHTML = ''; if (Object.keys(savedTags).length === 0) { savedListEl.innerHTML = '<div style="color:#999;text-align:center;padding:30px 10px;font-size:12px;">暂无保存的翻译</div>'; } else { // 获取排序模式 let entries = Object.entries(savedTags).map(([original, trans]) => { // 兼容旧常规模式 if (typeof trans === 'string') { return [original, { translation: trans, timestamp: 0 }]; } return [original, trans]; }); // 应用排序 const sortMode = LS.get('savedSortMode', 'tag-asc') || 'tag-asc'; if (sortMode === 'tag-asc') { entries.sort((a, b) => a[0].localeCompare(b[0])); } else if (sortMode === 'tag-desc') { entries.sort((a, b) => b[0].localeCompare(a[0])); } else if (sortMode === 'trans-asc') { entries.sort((a, b) => { const transA = typeof a[1] === 'string' ? a[1] : a[1].translation; const transB = typeof b[1] === 'string' ? b[1] : b[1].translation; return transA.localeCompare(transB); }); } else if (sortMode === 'trans-desc') { entries.sort((a, b) => { const transA = typeof a[1] === 'string' ? a[1] : a[1].translation; const transB = typeof b[1] === 'string' ? b[1] : b[1].translation; return transB.localeCompare(transA); }); } else if (sortMode === 'time-new') { entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); } else if (sortMode === 'time-old') { entries.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0)); } for (const [original, transData] of entries) { const translation = typeof transData === 'string' ? transData : transData.translation; const div = document.createElement('div'); div.style.cssText = 'padding:6px 8px;border:1px solid #d9d9d9;border-radius:4px;background:#e3f2fd;font-size:11px;display:flex;align-items:center;gap:4px;'; const origSpan = document.createElement('span'); origSpan.style.cssText = 'color:#1f6fff;font-weight:600;width:60px;max-width:60px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0;'; origSpan.title = original; origSpan.textContent = original; div.appendChild(origSpan); const arrow = document.createElement('span'); arrow.style.cssText = 'color:#999;flex-shrink:0;'; arrow.textContent = '→'; div.appendChild(arrow); // 编辑框 const input = document.createElement('input'); input.type = 'text'; input.value = translation; input.className = 'pteEditTranslation'; input.setAttribute('data-original', original); input.style.cssText = 'flex:1;padding:2px 4px;border:1px solid #d9d9d9;border-radius:3px;font-size:11px;min-width:40px;display:none;box-sizing:border-box;'; div.appendChild(input); const transSpan = document.createElement('span'); transSpan.style.cssText = 'color:#666;min-width:40px;max-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;word-break:break-all;margin-right:auto;'; transSpan.textContent = translation; transSpan.title = translation; transSpan.className = 'pteTransDisplay'; div.appendChild(transSpan); // 编辑按钮 const editBtn = document.createElement('button'); editBtn.className = 'pteEditTag'; editBtn.setAttribute('data-tag', original); editBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;'; editBtn.textContent = '✏️'; editBtn.onclick = (e) => { e.stopPropagation(); const isEditing = input.style.display !== 'none'; input.style.display = isEditing ? 'none' : 'block'; transSpan.style.display = isEditing ? 'block' : 'none'; editBtn.textContent = isEditing ? '✏️' : '✕'; saveBtn.style.display = isEditing ? 'none' : 'block'; if (!isEditing) input.focus(); }; div.appendChild(editBtn); // 保存修改按钮 const saveBtn = document.createElement('button'); saveBtn.className = 'pteSaveEdit'; saveBtn.setAttribute('data-tag', original); saveBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#67c23a;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;display:none;'; saveBtn.textContent = '💾'; saveBtn.onclick = (e) => { e.stopPropagation(); // 检查是否在排除列表中 if (excludeTagsSet.has(original)) { showToast(`❌ 此标签在排除列表中,无法保存`); return; } const newTranslation = input.value.trim(); if (!newTranslation) { showToast('翻译不能为空'); return; } // 保存翻译及时间戳 savedTags[original] = { translation: newTranslation, timestamp: Date.now() }; LS.set('tagTranslations', savedTags); // 从待翻译列表中删除该标签 const lines = transInput.value.split('\n'); const filtered = lines.filter(line => line.trim() !== original); transInput.value = filtered.join('\n'); updateTransInputList(); updateSavedList(); // 保存后重新应用搜索过滤 const searchInput = box.querySelector('#pteSavedSearch'); if (searchInput && searchInput.value.trim()) { searchInput.dispatchEvent(new Event('input')); } updateTransResultAfterExclude(); // 记录操作 addOperationLog('保存翻译', `${original} → ${newTranslation}`); updateOperationHistory(); showToast(`✅ 已保存修改:${original}`); }; div.appendChild(saveBtn); // 删除按钮 const deleteBtn = document.createElement('button'); deleteBtn.className = 'pteDeleteTag'; deleteBtn.setAttribute('data-tag', original); deleteBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#f56c6c;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;'; deleteBtn.textContent = '✕'; deleteBtn.onclick = (e) => { e.stopPropagation(); delete savedTags[original]; LS.set('tagTranslations', savedTags); // 同步到中间列:删除的翻译回到待翻译区 const currentText = transInput.value.trim(); const lines = currentText ? currentText.split('\n') : []; if (!lines.includes(original)) { lines.push(original); transInput.value = lines.join('\n'); } // 更新所有相关UI updateSavedList(); updateTransInputList(); // 删除后重新应用搜索过滤 const searchInput = box.querySelector('#pteSavedSearch'); if (searchInput && searchInput.value.trim()) { searchInput.dispatchEvent(new Event('input')); } // 检查翻译结果区是否有该标签,如果有则重新显示 const transResultItem = transResult.querySelector(`input[data-original="${original}"]`); if (transResultItem) { transResultItem.closest('div').style.display = 'flex'; } updateTransResultAfterExclude(); // 记录操作 addOperationLog('删除翻译', original); updateOperationHistory(); showToast(`✅ 已删除翻译,回到待翻译区:${original}`); }; div.appendChild(deleteBtn); savedListEl.appendChild(div); } } countEl.textContent = `(${Object.keys(savedTags).length})`; }; updateSavedList(); // 排除标签 let excludeTagsSet = new Set( excludedTags.split(',') .map(t => t.trim().replace(/^["']|["']$/g, '')) .filter(Boolean) ); // 调试:显示排除列表的实际内容 debugLog('EXCLUDE', '排除列表初始化', { rawExcludedTags: excludedTags, excludeTagsSet: Array.from(excludeTagsSet), size: excludeTagsSet.size }); // 排除标签的排序和时间戳 let excludeTagsWithTime = LS.get('excludeTagsWithTime', {}); let excludeSortMode = LS.get('excludeSortMode', 'alpha-asc'); // 'alpha-asc', 'alpha-desc', 'time-new', 'time-old' // 已存子标旧常排序上費会 let initialized = LS.get('excludeTagsTimeInitialized', false); if (!initialized && excludeTagsSet.size > 0) { const now = Date.now(); for (const tag of excludeTagsSet) { if (!excludeTagsWithTime[tag]) { excludeTagsWithTime[tag] = now; } } LS.set('excludeTagsWithTime', excludeTagsWithTime); LS.set('excludeTagsTimeInitialized', true); } const applySorting = () => { let sortedTags = Array.from(excludeTagsSet); switch (excludeSortMode) { case 'alpha-asc': sortedTags.sort(); break; case 'alpha-desc': sortedTags.sort().reverse(); break; case 'time-new': sortedTags.sort((a, b) => (excludeTagsWithTime[b] || 0) - (excludeTagsWithTime[a] || 0)); break; case 'time-old': sortedTags.sort((a, b) => (excludeTagsWithTime[a] || 0) - (excludeTagsWithTime[b] || 0)); break; } return sortedTags; }; const updateExcludeList = () => { const excludeListEl = box.querySelector('#pteExcludeList'); const countEl = box.querySelector('#pteExcludedCount'); excludeListEl.innerHTML = ''; const sortedTags = applySorting(); if (excludeTagsSet.size === 0) { excludeListEl.innerHTML = '<div style="color:#999;text-align:center;padding:30px 10px;font-size:12px;">暂无排除标签</div>'; } else { for (const tag of sortedTags) { const div = document.createElement('div'); div.style.cssText = 'padding:6px 8px;border:1px solid #d9d9d9;border-radius:4px;background:#ffebee;font-size:11px;display:flex;align-items:center;gap:6px;'; const tagSpan = document.createElement('span'); tagSpan.style.cssText = 'color:#c62828;font-weight:600;flex:1;word-break:break-all;'; tagSpan.textContent = tag; div.appendChild(tagSpan); const deleteBtn = document.createElement('button'); deleteBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#f56c6c;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;'; deleteBtn.textContent = '✕'; deleteBtn.onclick = (e) => { e.stopPropagation(); excludeTagsSet.delete(tag); delete excludeTagsWithTime[tag]; // 立即保存到 localStorage const tagsStr = Array.from(excludeTagsSet).join(','); LS.set('excludeTags', tagsStr); LS.set('excludeTagsWithTime', excludeTagsWithTime); // 同步到中间列:添加到待翻译区 const currentText = transInput.value.trim(); const lines = currentText ? currentText.split('\n') : []; if (!lines.includes(tag)) { lines.push(tag); transInput.value = lines.join('\n'); } // 同时从已保存翻译中删除该标签 if (savedTags[tag]) { delete savedTags[tag]; try { LS.set('tagTranslations', savedTags); } catch { } updateSavedList(); } updateExcludeList(); updateTransInputList(); updateTransResultAfterExclude(); showToast(`✅ 已移除排除标签,加入待翻译:${tag}`); }; div.appendChild(deleteBtn); excludeListEl.appendChild(div); } } countEl.textContent = `(${excludeTagsSet.size})`; }; updateExcludeList(); // 翻译结果实时更新(排除后) const updateTransResultAfterExclude = () => { const inputs = transResult.querySelectorAll('.pteTransEdit'); inputs.forEach(input => { const tag = input.dataset.original; const lowerTag = lower(tag); const isExcluded = Array.from(excludeTagsSet).some(ex => { return lower(ex) === lowerTag; }); // 如果已保存,保持隐藏;如果被排除,隐藏;否则显示 if (savedTags[tag]) { // 已保存的始终隐藏 input.closest('div').style.display = 'none'; } else if (isExcluded) { // 被排除的隐藏 input.closest('div').style.display = 'none'; } else { // 其他的显示 input.closest('div').style.display = 'flex'; } }); }; // 翻译相关 const transInput = box.querySelector('#pteTransInput'); const transInputList = box.querySelector('#pteTransInputList'); const transResult = box.querySelector('#pteTransResult'); // 管理待翻译标签卡片 const updateTransInputList = () => { transInputList.innerHTML = ''; const tags = transInput.value.trim().split('\n').map(t => t.trim()).filter(Boolean); if (tags.length === 0) { transInputList.innerHTML = '<div style="color:#999;text-align:center;padding:30px 10px;font-size:12px;">暂无待翻译标签</div>'; return; } tags.forEach(tag => { const div = document.createElement('div'); div.style.cssText = 'padding:6px 8px;border:1px solid #d9d9d9;border-radius:4px;background:#e3f5ff;font-size:11px;display:flex;align-items:center;justify-content:space-between;gap:4px;flex-wrap:wrap;'; const tagSpan = document.createElement('span'); tagSpan.style.cssText = 'color:#1976d2;font-weight:600;flex:1;word-break:break-all;min-width:0;'; tagSpan.textContent = tag; div.appendChild(tagSpan); // 按钮容器 const btnContainer = document.createElement('div'); btnContainer.style.cssText = 'display:flex;gap:2px;flex-shrink:0;'; // Pixiv搜索按钮 const pixivBtn = document.createElement('button'); pixivBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#409eff;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;min-width:24px;'; pixivBtn.textContent = 'P搜'; pixivBtn.title = '在Pixiv搜索此标签'; pixivBtn.onclick = (e) => { e.stopPropagation(); const searchUrl = `https://www.pixiv.net/tags/${encodeURIComponent(tag)}/illustrations`; window.open(searchUrl, '_blank'); }; btnContainer.appendChild(pixivBtn); // 搜索按钮(支持多个搜索引擎) const googleBtn = document.createElement('button'); googleBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#ffc107;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;'; googleBtn.textContent = '🔍'; googleBtn.onclick = (e) => { e.stopPropagation(); const settings = LS.get('tagManagerSettings', { searchEngine: 'google', customEngineUrl: '' }); const engineUrls = { google: `https://www.google.com/search?q=${encodeURIComponent(tag)}`, baidu: `https://www.baidu.com/s?wd=${encodeURIComponent(tag)}`, bing: `https://www.bing.com/search?q=${encodeURIComponent(tag)}`, 'yahoo-jp': `https://search.yahoo.co.jp/search?p=${encodeURIComponent(tag)}`, goo: `https://search.goo.ne.jp/web.jsp?MT=${encodeURIComponent(tag)}`, sogou: `https://www.sogou.com/web?query=${encodeURIComponent(tag)}` }; const engineNames = { google: 'Google', baidu: 'Baidu', bing: 'Bing', 'yahoo-jp': 'Yahoo Japan', goo: 'Goo', sogou: 'Sogou', custom: '自定义' }; let searchUrl; if (settings.searchEngine === 'custom' && settings.customEngineUrl) { searchUrl = settings.customEngineUrl.replace('{tag}', encodeURIComponent(tag)); } else { searchUrl = engineUrls[settings.searchEngine] || engineUrls.google; } googleBtn.title = `在 ${engineNames[settings.searchEngine]} 中搜索此标签`; debugLog('SEARCH', '用户选择搜索', { tag, engine: settings.searchEngine }); window.open(searchUrl, '_blank'); }; // 初始化title const initialSettings = LS.get('tagManagerSettings', { searchEngine: 'google', customEngineUrl: '' }); const engineNames = { google: 'Google', baidu: 'Baidu', bing: 'Bing', 'yahoo-jp': 'Yahoo Japan', goo: 'Goo', sogou: 'Sogou', custom: '自定义' }; googleBtn.title = `在 ${engineNames[initialSettings.searchEngine]} 中搜索此标签`; btnContainer.appendChild(googleBtn); // 排除按钮 const deleteBtn = document.createElement('button'); deleteBtn.style.cssText = 'padding:0 4px;border:none;border-radius:3px;background:#f56c6c;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;'; deleteBtn.textContent = '✕'; deleteBtn.title = '将此标签添加到排除列表'; deleteBtn.onclick = (e) => { e.stopPropagation(); // 将标签添加到排除列表 excludeTagsSet.add(tag); excludeTagsWithTime[tag] = Date.now(); // 保存到 localStorage const tagsStr = Array.from(excludeTagsSet).join(','); LS.set('excludeTags', tagsStr); LS.set('excludeTagsWithTime', excludeTagsWithTime); // 从待翻译列表中删除该标签 const lines = transInput.value.split('\n'); const filtered = lines.filter(line => line.trim() !== tag); transInput.value = filtered.join('\n'); updateTransInputList(); updateExcludeList(); updateTransResultAfterExclude(); showToast(`✅ 已排除: ${tag}`); }; btnContainer.appendChild(deleteBtn); div.appendChild(btnContainer); transInputList.appendChild(div); }); }; // 监听textarea的变化 transInput.addEventListener('input', updateTransInputList); // 监听搜索框的变化 const searchInput = box.querySelector('#pteSavedSearch'); searchInput.addEventListener('input', () => { const searchText = searchInput.value; const items = box.querySelectorAll('#pteSavedList > div'); items.forEach(item => { const transSpan = item.querySelector('.pteTransDisplay'); if (transSpan) { const trans = transSpan.textContent; // 只搜索翻译后的标签 const matches = pinyinMatch(trans, searchText); item.style.display = matches ? 'flex' : 'none'; } }); }); // 手动添加待翻译标签 const manualTransInput = box.querySelector('#pteManualTransInput'); const manualTransAddBtn = box.querySelector('#pteManualTransAdd'); manualTransAddBtn.onclick = () => { const tag = manualTransInput.value.trim(); if (!tag) { showToast('❌ 请输入标签'); return; } // 检查是否已存在 const existingTags = transInput.value.trim().split('\n').map(t => t.trim()).filter(Boolean); if (existingTags.includes(tag)) { showToast(`⚠️ 标签已存在`); return; } // 检查是否在排除列表中 if (excludeTagsSet.has(tag)) { showToast(`❌ 此标签在排除列表中`); return; } // 添加标签 if (transInput.value.trim()) { transInput.value += '\n' + tag; } else { transInput.value = tag; } manualTransInput.value = ''; updateTransInputList(); addOperationLog('手动添加待翻译', tag); updateOperationHistory(); showToast(`✅ 已添加待翻译标签:${tag}`); manualTransInput.focus(); }; // 回车键添加 manualTransInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { manualTransAddBtn.click(); } }); // 快速排除按钮 - 将待翻译栏中的标签添加到排除列表 box.querySelector('#pteQuickExclude').onclick = () => { const tags = transInput.value.trim().split('\n').map(t => t.trim()).filter(Boolean); if (!tags.length) { showToast('待翻译栏无标签'); return; } tags.forEach(t => { excludeTagsSet.add(t); excludeTagsWithTime[t] = Date.now(); }); const tagsStr = Array.from(excludeTagsSet).join(','); LS.set('excludeTags', tagsStr); LS.set('excludeTagsWithTime', excludeTagsWithTime); transInput.value = ''; updateTransInputList(); updateExcludeList(); updateTransResultAfterExclude(); addOperationLog('快速排除标签', `${tags.length} 个`); updateOperationHistory(); showToast(`✅ 已将 ${tags.length} 个标签加入排除列表`); }; // 提取选中作品标签 box.querySelector('#pteExtractTags').onclick = async () => { const checkboxes = document.querySelectorAll('.pxe-mini-checkbox:checked'); if (!checkboxes.length) { showToast('请先勾选要导入的作品'); return; } const extractBtn = box.querySelector('#pteExtractTags'); extractBtn.disabled = true; extractBtn.textContent = '⏳ 提取中...'; const allTags = new Set(); let processed = 0; let failed = 0; const totalWorks = checkboxes.length; // 从作业提取标签 for (const checkbox of checkboxes) { try { // 获取作品ID的多种方式 let illustId = null; // 方法1:从checkbox的value属性获取 if (checkbox.value && /^\d+$/.test(checkbox.value)) { illustId = checkbox.value; } // 方法2:从checkbox的data-id属性获取 if (!illustId && checkbox.dataset.id) { illustId = checkbox.dataset.id; } // 方法3:从nearby的 a 标签获取ID if (!illustId) { const link = checkbox.closest('[class*="item"], [class*="illust"], article')?.querySelector('a[href*="/artworks/"]'); if (link) { const match = link.href.match(/\/artworks\/(\d+)/); if (match) illustId = match[1]; } } // 方法4:从 name 属性获取(有些 checkbox 的 name 可能包含 ID) if (!illustId && checkbox.name) { const match = checkbox.name.match(/\d+/); if (match) illustId = match[0]; } if (!illustId) { debugLog('EXTRACT', '无法获取作品ID', { checkboxValue: checkbox.value }); console.warn('无法获取作品ID'); failed++; extractBtn.textContent = `已提取${processed}/${totalWorks}`; continue; } // 官方API获取信息 try { // 随机延迟300-800ms,防止请求过快导致被限流 const delay = Math.random() * 500 + 300; await new Promise(resolve => setTimeout(resolve, delay)); const info = await getJSON(`https://www.pixiv.net/ajax/illust/${illustId}`); if (info.body?.tags?.tags) { const tagList = info.body.tags.tags; // 只提取原始标签 (t.tag),不使用翻译 const tags = tagList.map(t => t?.tag).filter(Boolean); debugLog('EXTRACT', `作品 ${illustId} 提取标签`, { tagsCount: tags.length, tags: tags }); tags.forEach(t => allTags.add(t)); processed++; extractBtn.textContent = `已提取${processed}/${totalWorks}`; } else { failed++; extractBtn.textContent = `已提取${processed}/${totalWorks}`; } } catch (e) { console.error(`获取作品 ${illustId} 的标签失败:`, e); failed++; extractBtn.textContent = `已提取${processed}/${totalWorks}`; } } catch (e) { console.error('提取标签失败:', e); failed++; extractBtn.textContent = `已提取${processed}/${totalWorks}`; } } if (allTags.size === 0) { showToast(`未能提取到标签(成功${processed}件,失败${failed}件)。请检查:\n1. 是否在 Pixiv 列表页\n2. 是否正确勾选了作品`); extractBtn.disabled = false; extractBtn.textContent = '提取标签'; return; } // 检查是否已翻译 const savedTranslations = LS.get('tagTranslations', {}); const savedTagsList = Object.keys(savedTranslations); // 提取标签到输入框 const existingTags = transInput.value.trim().split('\n').filter(Boolean); // 直接从 allTags 中移除已保存的标签(不区分大小写) const tagsToFilter = new Set(); allTags.forEach(t => { const isSaved = savedTagsList.some(st => lower(st) === lower(t)); const isExcluded = Array.from(excludeTagsSet).some(ex => { const lowerEx = lower(ex); return lower(t).includes(lowerEx) || lowerEx.includes(lower(t)); }); if (!isSaved && !isExcluded) { tagsToFilter.add(t); } }); // 过滤:不重复(不在输入框中) const newTags = Array.from(tagsToFilter).filter(t => !existingTags.includes(t)); // 最重要:以实际待翻译区为准 const actualTransInputCount = transInput.value.trim().split('\n').filter(Boolean).length; // 重新梳理:所有标签应该被完整分类为:已保存 + 已排除 + 待翻译 + 新标签 // 使用互斥分类方式,结合 allTags 和实际待翻译区 const allTagsInUse = new Set([...Array.from(allTags), ...existingTags]); // 合并 allTags 和实际待翻译区的标签 const tagClassification = {}; // 记录每个标签的分类 Array.from(allTagsInUse).forEach(t => { // 先检查是否已保存(不区分大小写) const isSaved = savedTagsList.some(st => lower(st) === lower(t)); if (isSaved) { tagClassification[t] = 'saved'; } else { // 再检查是否已排除(精确匹配) const lowerTag = lower(t); const isExcluded = Array.from(excludeTagsSet).some(ex => { return lower(ex) === lowerTag; }); if (isExcluded) { tagClassification[t] = 'excluded'; } else if (existingTags.includes(t)) { tagClassification[t] = 'existing'; } else { tagClassification[t] = 'new'; } } }); // 计算各分类数量 const classifiedSaved = Object.entries(tagClassification).filter(([_, c]) => c === 'saved'); const classifiedExcluded = Object.entries(tagClassification).filter(([_, c]) => c === 'excluded'); const classifiedExisting = Object.entries(tagClassification).filter(([_, c]) => c === 'existing'); const classifiedNew = Object.entries(tagClassification).filter(([_, c]) => c === 'new'); const savedCount = classifiedSaved.length; const excludedCount = classifiedExcluded.length; const existingInTransInputCount = classifiedExisting.length; const totalTagsCount = allTagsInUse.size; // Debug 日志:标签分类结果 debugLog('EXTRACT', '标签分类完成', { total: totalTagsCount, new: classifiedNew.length, saved: classifiedSaved.length, excluded: classifiedExcluded.length, existing: classifiedExisting.length, savedTags: classifiedSaved.map(([t, _]) => t), // 显示具体的已保存标签 excludedTags: classifiedExcluded.map(([t, _]) => t), // 显示具体的已排除标签 newTags: classifiedNew.map(([t, _]) => t).slice(0, 10) // 只显示前10个 }); if (classifiedNew.length === 0) { const messages = [`所有标签已处理 (共${totalTagsCount}个)`]; if (savedCount > 0) messages.push(`${savedCount}个已保存`); if (excludedCount > 0) messages.push(`${excludedCount}个已排除`); if (existingInTransInputCount > 0) messages.push(`${existingInTransInputCount}个待翻译区中已有`); // 清空待翻译区(因为所有标签都已处理) transInput.value = ''; // 记录操作 addOperationLog('提取标签', `无新标签(共${totalTagsCount}个:${savedCount}个已保存,${excludedCount}个已排除,${existingInTransInputCount}个待翻译)`); updateOperationHistory(); // 确保待翻译列表显示 updateTransInputList(); showToast(messages.join(',')); } else { // 只添加新标签,清空之前的内容 const newTagsList = classifiedNew.map(([t, _]) => t); transInput.value = newTagsList.join('\n'); updateTransInputList(); const msgParts = [`✅ 已提取 ${classifiedNew.length} 个新标签(共${totalTagsCount}个)`]; if (savedCount > 0) msgParts.push(`${savedCount}个已保存被过滤`); if (excludedCount > 0) msgParts.push(`${excludedCount}个已排除`); if (existingInTransInputCount > 0) msgParts.push(`${existingInTransInputCount}个待翻译区中已有`); msgParts.push(`成功${processed}个作品`); addOperationLog('提取标签', `${classifiedNew.length}个新标签,共${totalTagsCount}个(${savedCount}个已保存,${excludedCount}个已排除,${existingInTransInputCount}个待翻译)`); updateOperationHistory(); showToast(msgParts.join(',')); } extractBtn.disabled = false; extractBtn.textContent = '提取标签'; }; box.querySelector('#pteTranslateAll').onclick = async () => { const tags = transInput.value.split('\n').map(t => t.trim()).filter(Boolean); if (!tags.length) { showToast('请输入至少一个标签'); return; } const btn = box.querySelector('#pteTranslateAll'); btn.disabled = true; btn.textContent = '⏳ 翻译中...'; transResult.innerHTML = ''; for (const tag of tags) { // 直接调用 AI 翻译 let translation = await translateWithQwen(tag, 'zh'); transResult.innerHTML += ` <div style="padding:6px 8px;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;gap:6px;font-size:11px;"> <span style="color:#1f6fff;font-weight:600;min-width:50px;max-width:50px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${tag}">${tag}</span> <span style="color:#999;">→</span> <input type="text" value="${translation}" class="pteTransEdit" data-original="${tag}" style="max-width:90px;padding:2px 4px;border:1px solid #d9d9d9;border-radius:3px;font-size:11px;box-sizing:border-box;" /> <button class="pteSaveOne" data-tag="${tag}" style="padding:2px 6px;border:none;border-radius:3px;background:#67c23a;color:#fff;cursor:pointer;font-size:10px;flex-shrink:0;">保存</button> </div> `; } btn.disabled = false; btn.textContent = '🚀 翻译全部'; // 更新"保存全部"按钮的状态 const saveAllBtn = box.querySelector('#pteSaveAll'); const hasTranslations = transResult.querySelectorAll('.pteTransEdit').length > 0; saveAllBtn.disabled = !hasTranslations; saveAllBtn.style.opacity = hasTranslations ? '1' : '0.5'; saveAllBtn.style.cursor = hasTranslations ? 'pointer' : 'not-allowed'; // 绑定保存按钮 transResult.querySelectorAll('.pteSaveOne').forEach(saveBtn => { saveBtn.onclick = () => { const tag = saveBtn.dataset.tag; const input = transResult.querySelector(`input[data-original="${tag}"]`); const translation = input.value.trim(); if (!translation) { showToast('翻译不能为空'); return; } savedTags[tag] = { translation: translation, timestamp: Date.now() }; LS.set('tagTranslations', savedTags); // 从待翻译区移除该标签 const lines = transInput.value.split('\n'); const filtered = lines.filter(line => line.trim() !== tag); transInput.value = filtered.join('\n'); // 更新所有相关UI updateSavedList(); updateTransInputList(); // 保存后重新应用搜索过滤 const searchInput = box.querySelector('#pteSavedSearch'); if (searchInput && searchInput.value.trim()) { searchInput.dispatchEvent(new Event('input')); } updateTransResultAfterExclude(); addOperationLog('保存翻译', tag); updateOperationHistory(); showToast(`✅ 已保存:${tag}`); // 更新"保存全部"按钮的状态 const saveAllBtn = box.querySelector('#pteSaveAll'); const hasTranslations = transResult.querySelectorAll('.pteTransEdit').length > 0; saveAllBtn.disabled = !hasTranslations; saveAllBtn.style.opacity = hasTranslations ? '1' : '0.5'; saveAllBtn.style.cursor = hasTranslations ? 'pointer' : 'not-allowed'; }; }); addOperationLog('翻译标签', `${tags.length} 个`); updateOperationHistory(); showToast(`✅ 翻译完成(${tags.length} 个)`); }; // 清空翻译结果 box.querySelector('#pteClearTransResult').onclick = () => { // 打开标签管理设置功能 const settingsDialog = document.createElement('div'); settingsDialog.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border:2px solid #409eff;border-radius:8px;padding:20px;z-index:2147483648;box-shadow:0 4px 16px rgba(0,0,0,0.2);min-width:400px;max-width:600px;'; settingsDialog.innerHTML = ` <div style="font-weight:600;color:#1f6fff;margin-bottom:16px;font-size:14px;">⚙️ 标签管理设置</div> <div style="color:#666;margin-bottom:16px;font-size:12px;line-height:1.8;"> <div style="margin-bottom:12px;"> <label style="display:block;margin-bottom:6px;font-weight:600;">搜索引擎选择</label> <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;"> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="google" style="cursor:pointer;" /> <span>Google</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="baidu" style="cursor:pointer;" /> <span>Baidu</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="bing" style="cursor:pointer;" /> <span>Bing</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="yahoo-jp" style="cursor:pointer;" /> <span>Yahoo Japan</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="goo" style="cursor:pointer;" /> <span>Goo</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="sogou" style="cursor:pointer;" /> <span>Sogou</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;"> <input type="radio" name="searchEngine" value="custom" style="cursor:pointer;" /> <span>自定义</span> </label> </div> <div style="color:#999;font-size:11px;margin-bottom:8px;">用于待翻译区的搜索按钮</div> <div style="display:none;" id="customEngineDiv" style="margin-bottom:8px;"> <label style="display:block;margin-bottom:4px;font-size:11px;font-weight:600;">自定义搜索 URL</label> <div style="display:flex;gap:6px;align-items:center;margin-bottom:6px;"> <input type="text" id="customEngineUrl" placeholder="输入网站名称或完整 URL" style="flex:1;padding:6px;border:1px solid #d9d9d9;border-radius:3px;font-size:11px;box-sizing:border-box;" /> <button id="customEnginePresets" style="padding:6px 12px;border:1px solid #d9d9d9;border-radius:3px;background:#f5f5f5;color:#666;cursor:pointer;font-size:11px;white-space:nowrap;">📋 内置</button> </div> <div id="presetMenu" style="display:none;position:absolute;background:#fff;border:1px solid #d9d9d9;border-radius:3px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:10000;min-width:200px;max-height:300px;overflow-y:auto;"> <div style="padding:6px;"> <div style="padding:6px;cursor:pointer;hover:background:#f0f0f0;" data-preset="pixiv-dic">Pixiv百科</div> <hr style="margin:4px 0;border:none;border-top:1px solid #e0e0e0;" /> <div style="padding:6px;cursor:pointer;hover:background:#f0f0f0;" data-preset="bing-translate">Bing翻译</div> <div style="padding:6px;cursor:pointer;hover:background:#f0f0f0;" data-preset="baidu">百度翻译</div> <div style="padding:6px;cursor:pointer;hover:background:#f0f0f0;" data-preset="deepl">DeepL翻译</div> <div style="padding:6px;cursor:pointer;hover:background:#f0f0f0;" data-preset="google-translate">Google翻译</div> <div style="padding:6px;cursor:pointer;hover:background:#f0f0f0;" data-preset="youdao">有道翻译</div> </div> </div> <div style="color:#999;font-size:10px;margin-top:8px;">💡 URL 格式说明:<br/>格式:https://site.com/search?q={tag}<br/>其中 {tag} 会被替换为搜索词</div> <div style="color:#999;font-size:10px;margin-top:4px;">💡 提示:输入网站名称(如 "pixiv")会自动识别,或输入完整 URL</div> </div> </div> <div style="margin-bottom:12px;"> <label style="display:flex;align-items:center;gap:8px;margin-bottom:8px;"> <input type="checkbox" id="settingDebugMode" style="cursor:pointer;" /> <span style="cursor:default;">Debug 模式</span> </label> <div style="color:#999;font-size:11px;margin-left:24px;">启用后会在浏览器控制台输出详细日志</div> </div> </div> <div style="display:flex;gap:10px;justify-content:flex-end;"> <button id="settingsCancel" style="padding:8px 16px;border:1px solid #d9d9d9;border-radius:6px;background:#f5f5f5;color:#666;cursor:pointer;font-weight:600;font-size:12px;">取消</button> <button id="settingsSave" style="padding:8px 16px;border:none;border-radius:6px;background:#409eff;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">保存设置</button> </div> `; const settingsMask = document.createElement('div'); settingsMask.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:2147483646;'; document.body.appendChild(settingsMask); document.body.appendChild(settingsDialog); // 加载当前设置 const currentSettings = LS.get('tagManagerSettings', { searchEngine: 'google', customEngineUrl: '', debugMode: false }); const searchEngineRadios = settingsDialog.querySelectorAll('input[name="searchEngine"]'); searchEngineRadios.forEach(radio => { if (radio.value === currentSettings.searchEngine) { radio.checked = true; } }); const customEngineDiv = settingsDialog.querySelector('#customEngineDiv'); const customEngineInput = settingsDialog.querySelector('#customEngineUrl'); customEngineInput.value = currentSettings.customEngineUrl || ''; // 切换自定义引擎输入框的显示 searchEngineRadios.forEach(radio => { radio.addEventListener('change', () => { if (radio.value === 'custom') { customEngineDiv.style.display = 'block'; } else { customEngineDiv.style.display = 'none'; } }); }); // 初始化显示状态 if (currentSettings.searchEngine === 'custom') { customEngineDiv.style.display = 'block'; } // 预设菜单功能 const presetMenu = settingsDialog.querySelector('#presetMenu'); const presetsBtn = settingsDialog.querySelector('#customEnginePresets'); const presetItems = presetMenu.querySelectorAll('[data-preset]'); customEnginePresets.onclick = (e) => { e.stopPropagation(); const presetMenu = settingsDialog.querySelector('#presetMenu'); presetMenu.style.display = presetMenu.style.display === 'none' ? 'block' : 'none'; }; customEngineInput.addEventListener('click', (e) => { e.stopPropagation(); }); presetItems.forEach(item => { item.onclick = () => { const preset = item.dataset.preset; // 根据预设值直接填入对应的URL const presetUrls = { 'pixiv-dic': 'https://dic.pixiv.net/a/{tag}', 'wiki-ja': 'https://ja.wikipedia.org/wiki/{tag}', 'moegirl': 'https://zh.moegirl.org.cn/{tag}', 'bluearchive': 'https://wiki.biligame.com/ba/Students', 'bluearchive-gk': 'https://www.gamekee.com/ba/', 'azurlane': 'https://wiki.biligame.com/azurlane/{tag}', 'bilibili-wiki': 'https://wiki.biligame.com/{tag}', 'google-translate': 'https://translate.google.com/?text={tag}', 'deepl': 'https://www.deepl.com/translator#en/ja/{tag}', 'youdao': 'https://fanyi.youdao.com/#/text?text={tag}', 'baidu': 'https://fanyi.baidu.com/#en/zh/{tag}', 'bing-translate': 'https://www.bing.com/translator' }; customEngineInput.value = presetUrls[preset] || preset; presetMenu.style.display = 'none'; }; }); // 点击其他地方关闭菜单 document.addEventListener('click', (e) => { if (!customEngineDiv.contains(e.target)) { presetMenu.style.display = 'none'; } }); const debugCheckbox = settingsDialog.querySelector('#settingDebugMode'); debugCheckbox.checked = currentSettings.debugMode || false; // 保存按钮 settingsDialog.querySelector('#settingsSave').onclick = () => { const selectedEngine = settingsDialog.querySelector('input[name="searchEngine"]:checked').value; let customUrl = customEngineInput.value.trim(); // 如果选择自定义且输入了URL,自动识别和补全 if (selectedEngine === 'custom' && customUrl) { // 自动识别常见的搜索引擎(支持模糊匹配) const urlPatterns = [ { keywords: ['pixiv', 'dic', '百科'], url: 'https://dic.pixiv.net/a/{tag}' }, { keywords: ['wiki', '维基'], url: 'https://ja.wikipedia.org/wiki/{tag}' }, { keywords: ['bilibili', '网页'], url: 'https://wiki.biligame.com/{tag}' }, { keywords: ['google', '谷歌'], url: 'https://translate.google.com/?text={tag}' }, { keywords: ['deepl', '深蓝'], url: 'https://www.deepl.com/translator#en/ja/{tag}' }, { keywords: ['youdao', '有道'], url: 'https://fanyi.youdao.com/#/text?text={tag}' }, { keywords: ['baidu', '百度'], url: 'https://fanyi.baidu.com/#en/zh/{tag}' }, { keywords: ['bing', '必应'], url: 'https://www.bing.com/translator' } ]; const lowerInput = customUrl.toLowerCase(); // 检查输入是否匹配任何已知的模式(模糊匹配) for (const pattern of urlPatterns) { if (pattern.keywords.some(keyword => lowerInput.includes(keyword))) { customUrl = pattern.url; break; } } } const settings = { searchEngine: selectedEngine, customEngineUrl: customUrl, debugMode: debugCheckbox.checked }; LS.set('tagManagerSettings', settings); debugLog('STORAGE', 'localStorage 保存设置', { tagManagerSettings: settings }); settingsDialog.remove(); settingsMask.remove(); showToast('✅ 设置已保存'); addOperationLog('修改设置', '标签管理设置'); updateOperationHistory(); }; // 取消按钮 settingsDialog.querySelector('#settingsCancel').onclick = () => { settingsDialog.remove(); settingsMask.remove(); }; settingsMask.onclick = () => { settingsDialog.remove(); settingsMask.remove(); }; }; // 导出列表(待翻译区的标签,JSON格式,无翻译结果) box.querySelector('#pteListExport').onclick = () => { const lines = transInput.value.split('\n').filter(line => line.trim()); if (lines.length === 0) { showToast('待翻译区没有标签'); return; } // 构建JSON对象,格式与导出翻译结果一致 const tagsData = {}; lines.forEach(tag => { tagsData[tag.trim()] = ''; // 值为空,表示未翻译 }); const data = { savedTags: tagsData }; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `PTE待翻译标签.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); addOperationLog('导出待翻译标签', `${lines.length} 个`); updateOperationHistory(); showToast(`✅ 已导出 ${lines.length} 个待翻译标签`); }; // 导入列表(从JSON文件导入标签到待翻译区) box.querySelector('#pteListImport').onclick = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target.result); const tagsToImport = data.savedTags || data; if (!tagsToImport || Object.keys(tagsToImport).length === 0) { showToast('❌ 无效的JSON格式或没有标签数据'); return; } // 获取当前待翻译区的标签 const currentLines = transInput.value.split('\n').filter(line => line.trim()); const currentSet = new Set(currentLines.map(l => l.trim())); // 添加新标签到待翻译区 const newTags = Object.keys(tagsToImport).filter(tag => !currentSet.has(tag.trim())); if (newTags.length > 0) { const existingContent = transInput.value.trim(); transInput.value = existingContent ? existingContent + '\n' + newTags.join('\n') : newTags.join('\n'); } updateTransInputList(); const totalImported = Object.keys(tagsToImport).length; const duplicates = totalImported - newTags.length; let message = `✅ 已导入 ${totalImported} 个待翻译标签`; if (duplicates > 0) { message += `(其中 ${duplicates} 个重复)`; } addOperationLog('导入待翻译标签', `${totalImported} 个`); updateOperationHistory(); showToast(message); } catch (err) { showToast('❌ 文件解析失败或JSON格式错误'); } }; reader.readAsText(file); } }; input.click(); }; // 保存所有翻译 box.querySelector('#pteSaveAll').onclick = () => { const inputs = transResult.querySelectorAll('.pteTransEdit'); if (!inputs.length) { showToast('没有翻译结果可保存'); return; } let count = 0; const tagsToRemoveFromInput = []; inputs.forEach(input => { const tag = input.dataset.original; const translation = input.value.trim(); // 检查是否在排除列表中 if (!excludeTagsSet.has(tag) && translation) { savedTags[tag] = { translation: translation, timestamp: Date.now() }; tagsToRemoveFromInput.push(tag); count++; // 保存后隐藏该翻译结果 input.closest('div').style.display = 'none'; } }); // 从待翻译区移除已保存的标签 if (tagsToRemoveFromInput.length > 0) { const lines = transInput.value.split('\n'); const filtered = lines.filter(line => !tagsToRemoveFromInput.includes(line.trim())); transInput.value = filtered.join('\n'); } LS.set('tagTranslations', savedTags); updateSavedList(); updateTransInputList(); updateTransResultAfterExclude(); if (count > 0) { addOperationLog('保存翻译', `${count} 个`); updateOperationHistory(); showToast(`✅ 已保存 ${count} 个翻译`); } else { showToast('❌ 没有可保存的翻译(排除列表中的标签不能保存)'); } }; // 左侧排除标签 - 导出 box.querySelector('#pteExcludeExport').onclick = () => { // 导出排除标签列表 const data = { excludeTags: Array.from(excludeTagsSet), excludeTagsWithTime: excludeTagsWithTime }; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `PTE排除标签.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); addOperationLog('导出排除标签', `${excludeTagsSet.size} 个`); updateOperationHistory(); showToast(`✅ 已导出 ${excludeTagsSet.size} 个排除标签`); }; // 左侧排除标签 - 导入 const createFileInput = (callback) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt,.csv'; input.onchange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { callback(event.target.result); }; reader.readAsText(file); } }; input.click(); }; // 通用导入处理函数(必须在导入对话框初始化之前定义) const processImport = (content, type) => { if (type === 'exclude') { let tags = content.trim(); if (tags.startsWith('[') && tags.endsWith(']')) { try { const parsed = JSON.parse(tags); if (Array.isArray(parsed)) { tags = parsed.join(','); } } catch (e) {} } tags = tags .replace(/^["']|["']$/gm, '') .replace(/[\n\r\t]+/g, ',') .replace(/\s*[,,]\s*/g, ',') .split(',') .map(t => t.trim()) .filter(Boolean) .join(','); if (tags) { const tagCount = tags.split(',').length; const now = Date.now(); tags.split(',').forEach(t => { const trimmedTag = t.trim(); excludeTagsSet.add(trimmedTag); if (!excludeTagsWithTime[trimmedTag]) { excludeTagsWithTime[trimmedTag] = now; } }); LS.set('excludeTagsWithTime', excludeTagsWithTime); updateExcludeList(); updateTransResultAfterExclude(); showToast(`✅ 已导入 ${tagCount} 个排除标签`); } else { showToast('❌ 内容为空或格式错误'); } } else if (type === 'saved') { // JSON格式 if (content.startsWith('{') && content.endsWith('}')) { try { const data = JSON.parse(content); if (data.savedTags && typeof data.savedTags === 'object') { const now = Date.now(); let imported = 0; Object.keys(data.savedTags).forEach(key => { const value = data.savedTags[key]; // 兼容旧格式和新格式 if (typeof value === 'string') { savedTags[key] = { translation: value, timestamp: now }; } else if (typeof value === 'object' && value.translation) { savedTags[key] = { translation: value.translation, timestamp: value.timestamp || now }; } imported++; }); if (imported > 0) { LS.set('tagTranslations', savedTags); updateSavedList(); showToast(`✅ 已导入 ${imported} 个翻译`); } else { showToast('❌ JSON格式错误:没有有效的翻译数据'); } return; } } catch (e) { // 失败继续文本 } } // 文本模式 const lines = content.trim().split('\n').filter(Boolean); let imported = 0; lines.forEach(line => { const [original, translation] = line.split('|').map(s => s.trim()); if (original && translation) { imported++; } }); if (imported === 0) { showToast('❌ 格式错误,应为:原始标签|翻译\n每行一条或JSON格式'); return; } lines.forEach(line => { const [original, translation] = line.split('|').map(s => s.trim()); if (original && translation) { savedTags[original] = { translation: translation, timestamp: Date.now() }; } }); LS.set('tagTranslations', savedTags); // 更新所有相关UI updateSavedList(); updateTransResultAfterExclude(); showToast(`✅ 已导入 ${imported} 个翻译`); } }; const importDialog = box.querySelector('#pteImportDialog'); const importMask = box.querySelector('#pteImportMask'); const fileInput = box.querySelector('#pteFileImportInput'); const showImportDialog = (type) => { currentImportType = type; importDialog.style.display = 'block'; importMask.style.display = 'block'; }; const hideImportDialog = () => { importDialog.style.display = 'none'; importMask.style.display = 'none'; currentImportType = null; }; // 左侧排除标签 - 导入按钮 box.querySelector('#pteExcludeImport').onclick = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target.result); if (data.excludeTags && Array.isArray(data.excludeTags)) { // 清空待添加的数据 excludeTagsSet.clear(); // 添加新数据 data.excludeTags.forEach(tag => { excludeTagsSet.add(tag); }); excludeTagsWithTime = data.excludeTagsWithTime || {}; // 添加时间戳 const now = Date.now(); data.excludeTags.forEach(tag => { if (!excludeTagsWithTime[tag]) { excludeTagsWithTime[tag] = now; } }); // 保存数据 const tagsStr = Array.from(excludeTagsSet).join(','); LS.set('excludeTags', tagsStr); LS.set('excludeTagsWithTime', excludeTagsWithTime); updateExcludeList(); updateTransResultAfterExclude(); addOperationLog('导入排除标签', `${data.excludeTags.length} 个`); updateOperationHistory(); showToast(`✅ 已导入 ${data.excludeTags.length} 个排除标签`); } else { showToast('❌ JSON格式错误'); } } catch (err) { showToast('❌ 文件解析失败'); } }; reader.readAsText(file); } }; input.click(); }; // 右侧已保存翻译 - 导入按钮 box.querySelector('#pteSavedImport').onclick = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target.result); if (data.savedTags && typeof data.savedTags === 'object') { // 确保有时间戳 const now = Date.now(); Object.keys(data.savedTags).forEach(key => { const value = data.savedTags[key]; // 兼容旧格式和新格式 if (typeof value === 'string') { data.savedTags[key] = { translation: value, timestamp: now }; } else if (typeof value === 'object' && !value.timestamp) { value.timestamp = now; } }); // 合并数据 Object.assign(savedTags, data.savedTags); LS.set('tagTranslations', savedTags); updateSavedList(); addOperationLog('导入翻译', `${Object.keys(data.savedTags).length} 个`); updateOperationHistory(); showToast(`✅ 已导入 ${Object.keys(data.savedTags).length} 个翻译`); } else { showToast('❌ JSON格式错误'); } } catch (err) { showToast('❌ 文件解析失败'); } }; reader.readAsText(file); } }; input.click(); }; // 导入对话框 const confirmBtn = box.querySelector('#pteImportConfirm'); if (confirmBtn) { confirmBtn.onclick = () => { const textarea = box.querySelector('#pteImportTextarea'); const content = textarea.value.trim(); if (!content) { showToast('❌ 请粘贴导入内容'); return; } hideImportDialog(); processImport(content, currentImportType); }; } else { console.warn('导入确认按钮未找到'); } const cancelBtn = box.querySelector('#pteImportCancel'); if (cancelBtn) { cancelBtn.onclick = () => { hideImportDialog(); }; } // 遮罩点击关闭对话框 importMask.onclick = hideImportDialog; // 处理文件输入 fileInput.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { processImport(event.target.result, currentImportType); }; reader.readAsText(file); }; // 手动添加排除标签 const manualInput = box.querySelector('#pteManualExcludeInput'); const addBtn = box.querySelector('#pteManualExcludeAdd'); addBtn.onclick = () => { const tag = manualInput.value.trim(); if (!tag) { showToast('❌ 请输入标签'); return; } excludeTagsSet.add(tag); excludeTagsWithTime[tag] = Date.now(); // 立即保存到 localStorage const tagsStr = Array.from(excludeTagsSet).join(','); console.log('[PTE-DEBUG] 添加标签前:', localStorage.getItem('pxeMini:excludeTags')); LS.set('excludeTags', tagsStr); LS.set('excludeTagsWithTime', excludeTagsWithTime); console.log('[PTE-DEBUG] 添加标签后 tagsStr:', tagsStr); console.log('[PTE-DEBUG] 添加标签后 LS.get("excludeTags"):', LS.get('excludeTags')); console.log('[PTE-DEBUG] 添加标签后 localStorage.getItem:', localStorage.getItem('pxeMini:excludeTags')); updateExcludeList(); updateTransResultAfterExclude(); addOperationLog('添加排除标签', tag); updateOperationHistory(); manualInput.value = ''; showToast(`✅ 已添加:${tag}`); }; manualInput.onkeypress = (e) => { if (e.key === 'Enter') { addBtn.click(); } }; // 排除标签排序 const sortBtn = box.querySelector('#pteExcludeSort'); const sortMenu = box.querySelector('#pteSortMenu'); const sortModeLabels = { 'alpha-asc': 'A→Z', 'alpha-desc': 'Z→A', 'time-new': '新→旧', 'time-old': '旧→新' }; const sortModeDesc = { 'alpha-asc': 'A→Z', 'alpha-desc': 'Z→A', 'tag-asc': '标A→Z', 'tag-desc': '标Z→A', 'trans-asc': '译A→Z', 'trans-desc': '译Z→A', 'time-new': '新→旧', 'time-old': '旧→新' }; sortBtn.onclick = (e) => { e.stopPropagation(); sortMenu.style.display = sortMenu.style.display === 'none' ? 'block' : 'none'; }; sortMenu.querySelectorAll('div[data-sort]').forEach(item => { item.onclick = (e) => { e.stopPropagation(); const newMode = item.getAttribute('data-sort'); excludeSortMode = newMode; LS.set('excludeSortMode', excludeSortMode); updateExcludeList(); sortMenu.style.display = 'none'; showToast(`✅ 已切换排序为: ${sortModeDesc[excludeSortMode]}`); }; }); // 点击页面其他地方关闭菜单 document.addEventListener('click', (e) => { if (!sortBtn.contains(e.target) && !sortMenu.contains(e.target)) { sortMenu.style.display = 'none'; } }); // 已保存翻译排序 const savedSortBtn = box.querySelector('#pteSavedSort'); const savedSortMenu = box.querySelector('#pteSavedSortMenu'); let savedSortMode = LS.get('savedSortMode', 'alpha-asc') || 'alpha-asc'; savedSortBtn.onclick = (e) => { e.stopPropagation(); savedSortMenu.style.display = savedSortMenu.style.display === 'none' ? 'block' : 'none'; }; savedSortMenu.querySelectorAll('div[data-sort]').forEach(item => { item.onclick = (e) => { e.stopPropagation(); const newMode = item.getAttribute('data-sort'); savedSortMode = newMode; LS.set('savedSortMode', savedSortMode); updateSavedList(); savedSortMenu.style.display = 'none'; showToast(`✅ 已切换排序为: ${sortModeDesc[savedSortMode]}`); }; }); // 点击页面其他地方关闭已保存翻译的排序菜单 document.addEventListener('click', (e) => { if (!savedSortBtn.contains(e.target) && !savedSortMenu.contains(e.target)) { savedSortMenu.style.display = 'none'; } }); // 左侧排除标签 - 保存 box.querySelector('#pteExcludeSave').onclick = () => { const tags = Array.from(excludeTagsSet).join(','); CFG.filters.excludeTags = tags; try { LS.set('excludeTags', tags); } catch { } LS.set('excludeTagsWithTime', excludeTagsWithTime); LS.set('excludeSortMode', excludeSortMode); const sortModeNames = { 'alpha-asc': 'A→Z', 'alpha-desc': 'Z→A', 'time-new': '新→旧', 'time-old': '旧→新' }; addOperationLog('保存排除标签', `${excludeTagsSet.size} 个`); updateOperationHistory(); showToast(`✅ 已保存 ${excludeTagsSet.size} 个排除标签(排序: ${sortModeNames[excludeSortMode]})`); }; // 清空前要求确认 box.querySelector('#pteExcludeReset').onclick = () => { // 创建自定义确认对话框 const confirmDialog = document.createElement('div'); confirmDialog.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border:2px solid #f56c6c;border-radius:8px;padding:20px;z-index:2147483648;box-shadow:0 4px 16px rgba(0,0,0,0.2);min-width:300px;'; confirmDialog.innerHTML = ` <div style="font-weight:600;color:#f56c6c;margin-bottom:16px;font-size:14px;">⚠️ 确认清空</div> <div style="color:#666;margin-bottom:20px;font-size:12px;">确定要清空所有排除标签吗?此操作无法撤销。</div> <div style="display:flex;gap:10px;justify-content:flex-end;"> <button id="pteConfirmCancel" style="padding:8px 16px;border:1px solid #d9d9d9;border-radius:6px;background:#f5f5f5;color:#666;cursor:pointer;font-weight:600;font-size:12px;">取消</button> <button id="pteConfirmOk" style="padding:8px 16px;border:none;border-radius:6px;background:#f56c6c;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">清空</button> </div> `; const mask = document.createElement('div'); mask.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:2147483647;'; document.body.appendChild(mask); document.body.appendChild(confirmDialog); const cancelBtn = confirmDialog.querySelector('#pteConfirmCancel'); const okBtn = confirmDialog.querySelector('#pteConfirmOk'); const cleanup = () => { document.body.removeChild(mask); document.body.removeChild(confirmDialog); }; cancelBtn.onclick = cleanup; mask.onclick = cleanup; okBtn.onclick = () => { cleanup(); // 执行清空操作 excludeTagsSet.clear(); excludeTagsWithTime = {}; LS.set('excludeTagsWithTime', excludeTagsWithTime); CFG.filters.excludeTags = ''; LS.set('excludeTags', ''); updateExcludeList(); updateTransResultAfterExclude(); addOperationLog('清空排除标签', ''); updateOperationHistory(); showToast('✅ 已清空排除列表'); }; }; // 导出已保存翻译 box.querySelector('#pteSavedExport').onclick = () => { // 按当前排序模式导出 const sortMode = LS.get('savedSortMode', 'tag-asc') || 'tag-asc'; let entries = Object.entries(savedTags); if (sortMode === 'tag-asc') { entries.sort((a, b) => a[0].localeCompare(b[0])); } else if (sortMode === 'tag-desc') { entries.sort((a, b) => b[0].localeCompare(a[0])); } else if (sortMode === 'trans-asc') { entries.sort((a, b) => { const transA = typeof a[1] === 'string' ? a[1] : a[1].translation; const transB = typeof b[1] === 'string' ? b[1] : b[1].translation; return transA.localeCompare(transB); }); } else if (sortMode === 'trans-desc') { entries.sort((a, b) => { const transA = typeof a[1] === 'string' ? a[1] : a[1].translation; const transB = typeof b[1] === 'string' ? b[1] : b[1].translation; return transB.localeCompare(transA); }); } else if (sortMode === 'time-new') { entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); } else if (sortMode === 'time-old') { entries.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0)); } // 转为简化格式:只导出翻译文本,不包含时间戳 const exportTags = {}; entries.forEach(([key, value]) => { exportTags[key] = typeof value === 'string' ? value : value.translation; }); const data = { savedTags: exportTags }; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `PTE翻译结果.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); addOperationLog('导出翻译', `${Object.keys(savedTags).length} 个`); updateOperationHistory(); showToast(`✅ 已导出 ${Object.keys(savedTags).length} 个翻译(按${sortMode}排序)`); }; // 右侧已保存翻译 - 保存 box.querySelector('#pteSavedSave').onclick = () => { const content = JSON.stringify(savedTags); try { LS.set('tagTranslations', content); } catch { } addOperationLog('保存翻译', `${Object.keys(savedTags).length} 个`); updateOperationHistory(); showToast(`✅ 已保存 ${Object.keys(savedTags).length} 个翻译`); }; // 清空已保存翻译 box.querySelector('#pteSavedReset').onclick = () => { // 创建自定义确认对话框 const confirmDialog = document.createElement('div'); confirmDialog.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border:2px solid #f56c6c;border-radius:8px;padding:20px;z-index:2147483648;box-shadow:0 4px 16px rgba(0,0,0,0.2);min-width:300px;'; confirmDialog.innerHTML = ` <div style="font-weight:600;color:#f56c6c;margin-bottom:16px;font-size:14px;">⚠️ 确认清空</div> <div style="color:#666;margin-bottom:20px;font-size:12px;">确定要清空所有已保存的翻译吗?此操作无法撤销。</div> <div style="display:flex;gap:10px;justify-content:flex-end;"> <button id="pteConfirmCancel" style="padding:8px 16px;border:1px solid #d9d9d9;border-radius:6px;background:#f5f5f5;color:#666;cursor:pointer;font-weight:600;font-size:12px;">取消</button> <button id="pteConfirmOk" style="padding:8px 16px;border:none;border-radius:6px;background:#f56c6c;color:#fff;cursor:pointer;font-weight:600;font-size:12px;">清空</button> </div> `; const mask = document.createElement('div'); mask.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:2147483647;'; document.body.appendChild(mask); document.body.appendChild(confirmDialog); const cancelBtn = confirmDialog.querySelector('#pteConfirmCancel'); const okBtn = confirmDialog.querySelector('#pteConfirmOk'); const cleanup = () => { document.body.removeChild(mask); document.body.removeChild(confirmDialog); }; cancelBtn.onclick = cleanup; mask.onclick = cleanup; okBtn.onclick = () => { cleanup(); // 执行清空操作 for (const key in savedTags) { delete savedTags[key]; } LS.set('tagTranslations', savedTags); // 更新所有相关UI updateSavedList(); updateTransResultAfterExclude(); addOperationLog('清空翻译', ''); updateOperationHistory(); showToast('✅ 已清空已保存翻译'); }; }; // 初始化操作历史显示 updateOperationHistory(); // 关闭前清空待翻译区的数据 const saveBeforeClose = (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); // 关闭时不保存,清空待翻译数据 try { localStorage.removeItem(LSKEY + ':manualTags'); } catch { } if (mask && mask.parentNode) { mask.parentNode.removeChild(mask); } return false; }; // 关闭 const closeBtn = box.querySelector('#pteManagerClose'); if (closeBtn) { closeBtn.addEventListener('click', saveBeforeClose); } // 防止误触背景:改为需要点击关闭按钮才能关闭 mask.addEventListener('click', (e) => { if (e.target === mask) e.stopPropagation(); }); } /******************** 模式 & 本地下载工具 ********************/ const COLOR = { eagle: '#409eff', disk: '#f1a72e' }; // 蓝(鹰) / 偏黄(本地) function fmtIndex(i, total) { const w = String(total).length; return String(i).padStart(w, '0'); } function inferExtFromUrl(u) { const m = u.match(/\.([a-zA-Z0-9]+)(?:\?|$)/); return m ? ('.' + m[1].toLowerCase()) : '.jpg'; } function gmDownloadWithHeaders(url, name, headers) { // Disk 模式 + FS if (typeof PTE_FS !== 'undefined' && PTE_FS && PTE_FS.root && (typeof CFG === 'object') && CFG.mode === 'disk') { return (async () => { const ab = await gmFetchBinary(url, { headers: headers || {} }); const blob = new Blob([ab]); await saveBlobAsWithPath(name, blob); })(); } // 回退:GM_download(无法创建子目录,仅作兜底) return new Promise((resolve, reject) => { try { GM_download({ url, name, saveAs: false, headers: headers || {}, onload: resolve, onerror: reject, ontimeout: reject }); } catch (e) { reject(e); } }); } // ====== FS Access helpers (user-gesture required once) ====== let PTE_FS = { root: null, picked: false }; async function ptePickDownloadsRoot() { if (!('showDirectoryPicker' in window)) { showToast('当前浏览器不支持选择目录(需要 Chrome/Edge 版本较新)'); return false; } try { const root = await window.showDirectoryPicker({ id: 'pte-download-root', mode: 'readwrite', startIn: 'downloads' }); PTE_FS.root = root; PTE_FS.picked = true; showToast('已选择下载目录:Downloads/Pixiv'); return true; } catch (e) { console.warn('目录选择取消或失败', e); showToast('未选择目录,继续使用浏览器默认下载(无法创建子文件夹)'); return false; } } async function pteSaveWithFS(path, blob) { if (!PTE_FS.root) return false; try { const parts = path.split('/').filter(Boolean); let dir = PTE_FS.root; for (let i = 0; i < parts.length - 1; i++) { dir = await dir.getDirectoryHandle(parts[i], { create: true }); } const fname = parts[parts.length - 1]; const fileHandle = await dir.getFileHandle(fname, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); return true; } catch (e) { console.warn('FS Access 写入失败,回退 GM_download', e); return false; } } async function saveBlobAsWithPath(path, blob) { const url = URL.createObjectURL(blob); try { if (PTE_FS.root) { const ok = await pteSaveWithFS(path, blob); if (ok) { URL.revokeObjectURL(url); return; } } } catch (e) { console.warn(e); } return new Promise((resolve, reject) => { const cleanup = () => { setTimeout(() => URL.revokeObjectURL(url), 2000); }; try { GM_download({ url, name: path, saveAs: false, onload: () => { cleanup(); resolve(); }, onerror: (e) => { cleanup(); reject(e); }, ontimeout: (e) => { cleanup(); reject(e); } }); } catch (e) { cleanup(); reject(e); } }); } // 统一请求处理 function gmFetch(url, options = {}) { const { method = 'GET', body = null, headers = {}, responseType = 'text' } = options; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, data: body, headers, responseType: responseType === 'json' ? 'text' : responseType, onload: (res) => { if (responseType === 'json') { try { resolve(JSON.parse(res.responseText || '{}')); } catch { resolve({}); } } else { resolve(res.response || res.responseText); } }, onerror: reject, ontimeout: reject }); }); } // 向后兼容的快捷函数 function gmFetchBinary(url, options = {}) { return gmFetch(url, { ...options, responseType: 'arraybuffer' }); } function gmFetchText(url, options = {}) { return gmFetch(url, { ...options, responseType: 'text' }); } async function ensureFflateLoaded() { if (window.fflate) return; throw new Error('fflate 未加载(@require 失败)'); } let __gifWorkerURL = null; async function ensureGifLibLoaded() { if (!window.GIF) throw new Error('gif.js 未加载(@require 失败)'); if (!__gifWorkerURL) { const workerCode = await gmFetchText('https://cdn.jsdelivr.net/npm/[email protected]/dist/gif.worker.js'); __gifWorkerURL = URL.createObjectURL(new Blob([workerCode], { type: 'text/javascript' })); } } function guessMime(name) { return name.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } function decodeImageFromU8(u8, mime) { return new Promise((resolve, reject) => { const blob = new Blob([u8], { type: mime }); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); }; img.src = url; }); } // GIF 转换逻辑 const GifHelper = { async convertToGifBlob(artId) { await ensureFflateLoaded(); await ensureGifLibLoaded(); const meta = await ugoiraMeta(artId); const zipUrl = meta?.body?.originalSrc || meta?.body?.src; const frames = meta?.body?.frames || []; if (!zipUrl || !frames.length) throw new Error('无法获取动图元数据'); const zipBuf = await gmFetchBinary(zipUrl, { responseType: 'arraybuffer', headers: { referer: 'https://www.pixiv.net/' } }); const entries = window.fflate.unzipSync(new Uint8Array(zipBuf)); const first = frames[0]; const firstBytes = entries[first.file]; if (!firstBytes) throw new Error('压缩包缺少首帧: ' + first.file); const firstImg = await decodeImageFromU8(firstBytes, guessMime(first.file)); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); canvas.width = firstImg.width; canvas.height = firstImg.height; const gif = new window.GIF({ workers: 2, quality: 10, width: canvas.width, height: canvas.height, workerScript: __gifWorkerURL }); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(firstImg, 0, 0); gif.addFrame(ctx, { copy: true, delay: Math.max(20, first.delay || 100) }); for (let i = 1; i < frames.length; i++) { const f = frames[i]; const bytes = entries[f.file]; if (!bytes) throw new Error('压缩包缺少帧: ' + f.file); const img = await decodeImageFromU8(bytes, guessMime(f.file)); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); gif.addFrame(ctx, { copy: true, delay: Math.max(20, f.delay || 100) }); } const blob = await new Promise(resolve => { gif.on('finished', b => resolve(b)); gif.render(); }); return blob; }, async saveAndGetDataURL(artId, title, { saveLocal = true, savePath = null, needDataURL = true } = {}) { const blob = await this.convertToGifBlob(artId); const safeTitle = sanitize(title || ''); const baseName = safeTitle || `pixiv_${artId}`; const trimmedBase = baseName.length > 80 ? baseName.slice(0, 80) : baseName; const name = `${trimmedBase}_${artId}.gif`; if (saveLocal) { if (savePath) { await saveBlobAsWithPath(savePath, blob); } else { saveBlobAs(name, blob); } } let dataURL = null; if (needDataURL) { dataURL = await blobToDataURL(blob); } return { blob, dataURL, name }; } }; function saveBlobAs(filename, blob) { const url = URL.createObjectURL(blob); const cleanup = () => setTimeout(() => URL.revokeObjectURL(url), 2000); try { if (typeof GM_download === 'function') { GM_download({ url, name: filename, saveAs: false, onload: cleanup, ontimeout: cleanup, onerror: () => { cleanup(); fallback(); } }); return; } } catch { cleanup(); } fallback(); function fallback() { const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); cleanup(); } } function blobToDataURL(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } /******************** 导入 / 合并行为 ********************/ async function importMode(mode) { cancel = false; aborters.clear(); if (mode === 'one') { const id = location.pathname.match(/artworks\/(\d+)/)?.[1]; if (!id) { showToast('未识别到作品ID'); return; } return importOne(id, /*mergeGif*/ true); } showScan(); let ids = []; const onUser = isUser(); if (mode === 'selected') { const cbs = [...document.querySelectorAll('.pxe-mini-checkbox:checked')]; ids = [...new Set(cbs.map(cb => cb.dataset.id).filter(Boolean))]; updScan(ids.length, 0, true); } else if (mode === 'page') { ids = collectIdsFromPage(); updScan(ids.length, 0, true); } else if (mode === 'all') { if (onUser) { const m = location.pathname.match(/users\/(\d+)/); if (!m) { closeScan(); showToast('未识别到用户ID'); return; } const uid = m[1]; ids = await allIllustIds(uid); updScan(ids.length, 0, true); } else { ids = collectIdsFromPage(); updScan(ids.length, 0, true); } } if (cancel) { closeScan(); return; } if (!ids.length) { closeScan(); showToast(mode === 'selected' ? '请先勾选作品' : '未在本页找到作品'); return; } // 修正标签中的引号和特殊字符 const cleanExcludeTag = (tag) => { return lower(tag.trim().replace(/^["']|["']$/g, '')); }; const wants = (CFG.filters.excludeTags || '') .split(',') .map(cleanExcludeTag) .filter(Boolean); const savedTranslations = LS.get('tagTranslations', {}); // 标签优化流程 const cleanTags = (tags) => { if (!wants.length) return tags || []; return (tags || []).filter(t => { const lowerTag = lower(t); return !wants.some(ex => lowerTag === ex); }); }; const processTagsWithTranslation = (tags) => { // 第1步:先过排除规则 const filtered = cleanTags(tags || []); // 第2步:应用翻译 const translated = filtered.map(t => { const trans = savedTranslations[t]; if (!trans) return t; // 兼容旧格式(字符串)和新格式(对象) return typeof trans === 'string' ? trans : trans.translation || t; }); // 第3步:去重 return Array.from(new Set(translated)); }; closeScan(); showImport(ids.length); let kept = []; let done = 0; let ok = 0; updImport(0, ids.length, 0); for (const id of ids) { if (cancel) break; try { const info = await illustInfoAndPages(id); if (cancel) break; const baseCommon = { website: `https://www.pixiv.net/artworks/${id}` }; const modTime = (CFG.feature.useUploadAsAddDate && info.uploadDate) ? new Date(info.uploadDate).getTime() : undefined; if (CFG.mode === 'eagle') { let items = []; if (info.illustType === 2) { // ugoira→GIF:优先 Eagle,超閾转本地 const blob = await GifHelper.convertToGifBlob(id); if (blob.size > BIG_GIF_LIMIT) { const safeUser = sanitize(info.userName || ("Pixiv_" + info.userId)); const baseDir = `Pixiv/${safeUser}_${info.userId}`; const savePath = `${baseDir}/${id}.gif`; await saveBlobAsWithPath(savePath, blob); bigGifFallbacks.push({ id, size: blob.size, path: savePath, userName: info.userName, userId: info.userId }); ok++; } else { const safeTitle = sanitize(info.title || ''); const baseName = safeTitle || `pixiv_${id}`; const trimmedBase = baseName.length > 80 ? baseName.slice(0, 80) : baseName; const name = `${trimmedBase}_${id}.gif`; const dataURL = await blobToDataURL(blob); const processedTags = processTagsWithTranslation(info.tags || []); const one = { url: dataURL, name: name, tags: Array.from(new Set([...processedTags, info.userName].filter(Boolean))) }; if (modTime) one.modificationTime = modTime; items.push({ ...baseCommon, ...one }); } } else { const rng = parseRange(CFG.filters.pageRange); const urls = info.pageUrls || []; let use = urls; if (rng) use = urls.filter((_, i) => { const p = i + 1; return p >= rng[0] && p <= rng[1]; }); let i = 0; items = use.map(u => { const processedTags = processTagsWithTranslation(info.tags || []); const one = { url: u, name: use.length > 1 ? `${info.title}_p${++i}` : info.title, tags: Array.from(new Set([...processedTags, info.userName].filter(Boolean))), headers: { referer: 'https://www.pixiv.net/' } }; if (modTime) one.modificationTime = modTime; return { ...baseCommon, ...one }; }); } const fid = await ensureArtistFolder(info.userId, info.userName); if (items.length) { await addToEagle(items, fid); ok++; } } else { // Disk 模式:保存到 Downloads/Pixiv/作者ID/ 目录 const safeUser = sanitize(info.userName || ("Pixiv_" + info.userId)); const baseDir = `Pixiv/${safeUser}_${info.userId}`; if (info.illustType === 2) { const savePath = `${baseDir}/${id}.gif`; await GifHelper.saveAndGetDataURL(id, info.title, { saveLocal: true, savePath, needDataURL: false }); ok++; } else { const rng = parseRange(CFG.filters.pageRange); const urls = info.pageUrls || []; let use = urls; if (rng) use = urls.filter((_, i) => { const p = i + 1; return p >= rng[0] && p <= rng[1]; }); const total = use.length || 1; for (let i = 0; i < use.length; i++) { const u = use[i]; const ext = inferExtFromUrl(u); const fname = total > 1 ? `${baseDir}/${id}_${fmtIndex(i + 1, total)}${ext}` : `${baseDir}/${id}${ext}`; await gmDownloadWithHeaders(u, fname, { referer: 'https://www.pixiv.net/' }); } ok++; } } } catch (e) { console.warn('[导入失败]', id, e); } done++; updImport(done, ids.length, ok); await sleep(120); if (cancel) break; } const filtered = done - ok; let msg = cancel ? `已取消。处理${done},成功${ok}` : `导入完成!处理${done},成功${ok}`; if (bigGifFallbacks && bigGifFallbacks.length) { const lines = bigGifFallbacks.map(f => `- 作品 ${f.id}(约 ${(f.size / 1024 / 1024).toFixed(1)}MB)已自动切换为“保存到本地”,路径:${f.path}`); msg += `\n\n以下动图因体积较大,已自动使用本地模式保存(未导入 Eagle):\n${lines.join('\n')}\n\n原因:浏览器/油猴在导入超大 GIF 到 Eagle 时,可能触发内部“消息长度超限”(Message length exceeded maximum allowed length),从而导致任务卡住。当前版本通过自动切换本地模式规避此问题。`; } showToast(msg, 5000); bigGifFallbacks = []; document.getElementById('pxeMiniProg')?.remove(); } async function importOne(id, mergeGif = false) { cancel = false; try { const info = await illustInfoAndPages(id); // 获取已保存的翻译 const savedTranslations = LS.get('tagTranslations', {}); // 应用标签过滤(移除引号) const cleanExcludeTag = (tag) => { return lower(tag.trim().replace(/^["']|["']$/g, '')); }; const excludeTags = (CFG.filters.excludeTags || '') .split(',') .map(cleanExcludeTag) .filter(Boolean); const cleanTags = (tags) => { if (!excludeTags.length) return tags || []; return (tags || []).filter(t => { const lowerTag = lower(t); return !excludeTags.some(ex => lowerTag === ex); }); }; // 应用翻译:将标签替换为已保存的翻译(如果存在),然后去重 const processTagsWithTranslation = (tags) => { // 第1步:先过排除规则 const filtered = cleanTags(tags || []); // 第2步:应用翻译 const translated = filtered.map(t => { const trans = savedTranslations[t]; if (!trans) return t; // 兼容旧格式(字符串)和新格式(对象) return typeof trans === 'string' ? trans : (trans.translation || t); }); // 第3步:去重(这样可以避免两个不同日文标签翻译成同一个中文标签时出现重复) return Array.from(new Set(translated)); }; // 统一的标题截断处理 const truncateTitle = (title) => { const safeTitle = sanitize(title || ''); const baseName = safeTitle || `pixiv_${id}`; return baseName.length > 80 ? baseName.slice(0, 80) : baseName; }; const baseCommon = { website: `https://www.pixiv.net/artworks/${id}` }; const modTime = (CFG.feature.useUploadAsAddDate && info.uploadDate) ? new Date(info.uploadDate).getTime() : undefined; const rng = parseRange(CFG.filters.pageRange); const urls = info.pageUrls || []; if (CFG.mode === 'eagle') { const fid = await ensureArtistFolder(info.userId, info.userName); let items = []; if (info.illustType === 2) { // 生成 GIF:若体积过大则自动切换为本地模式保存 const blob = await GifHelper.convertToGifBlob(id); if (blob.size > BIG_GIF_LIMIT) { const safeUser = sanitize(info.userName || ("Pixiv_" + info.userId)); const baseDir = `Pixiv/${safeUser}_${info.userId}`; const savePath = `${baseDir}/${id}.gif`; await saveBlobAsWithPath(savePath, blob); showToast(`已完成:动图体积约 ${(blob.size / 1024 / 1024).toFixed(1)}MB,已自动切换为“保存到本地”模式并保存到\n${savePath}\n\n原因:浏览器/油猴在导入超大 GIF 到 Eagle 时,可能触发内部“消息长度超限”限制,导致任务卡住。`, 4000); return; } else { const baseName = truncateTitle(info.title); const name = `${baseName}_${id}.gif`; const dataURL = await blobToDataURL(blob); const processedTags = processTagsWithTranslation(info.tags || []); const one = { url: dataURL, name: name, tags: Array.from(new Set([...processedTags, info.userName].filter(Boolean))) }; if (modTime) one.modificationTime = modTime; items.push({ ...baseCommon, ...one }); } } else { let use = urls; if (rng) use = urls.filter((_, i) => { const p = i + 1; return p >= rng[0] && p <= rng[1]; }); let i = 0; const baseName = truncateTitle(info.title); const processedTags = processTagsWithTranslation(info.tags || []); items = use.map(u => { const itemName = use.length > 1 ? `${baseName}_p${++i}` : baseName; const one = { url: u, name: itemName, tags: Array.from(new Set([...processedTags, info.userName].filter(Boolean))), headers: { referer: 'https://www.pixiv.net/' } }; if (modTime) one.modificationTime = modTime; return { ...baseCommon, ...one }; }); } if (items.length) { await addToEagle(items, fid); } showToast('已完成:已发送到 Eagle' + (info.illustType === 2 ? '(GIF 已导入)' : '')); } else { // Disk 模式:保存到 Downloads/Pixiv/作者ID/ 目录 const safeUser = sanitize(info.userName || ("Pixiv_" + info.userId)); const baseDir = `Pixiv/${safeUser}_${info.userId}`; if (info.illustType === 2) { const savePath = `${baseDir}/${id}.gif`; await GifHelper.saveAndGetDataURL(id, info.title, { saveLocal: true, savePath, needDataURL: false }); } else { let use = urls; if (rng) use = urls.filter((_, i) => { const p = i + 1; return p >= rng[0] && p <= rng[1]; }); const total = use.length || 1; for (let i = 0; i < use.length; i++) { const u = use[i]; const ext = inferExtFromUrl(u); const fname = total > 1 ? `${baseDir}/${id}_${fmtIndex(i + 1, total)}${ext}` : `${baseDir}/${id}${ext}`; await gmDownloadWithHeaders(u, fname, { referer: 'https://www.pixiv.net/' }); } } showToast(`已完成:已保存到本地 ${baseDir}`); } } catch (e) { showToast('发送/下载失败:' + (e && e.message || e), 4000); } } /******************** 作者文件夹 ********************/ async function ensureArtistFolder(uid, userName, parentId = null) { // 根据作者 uid / 名称在 Eagle 中找到或创建对应文件夹,并写入 pid 备注 const folders = await listFolders(); const all = flattenFolders(folders); const hasUid = uid !== undefined && uid !== null && uid !== ''; const uidStr = hasUid ? String(uid) : ''; if (hasUid) { const pidRe = /pid\s*=\s*(\d+)/; const hit = all.find(f => { const m = (f.description || '').match(pidRe); return m && m[1] === uidStr; }); if (hit) return hit.id; } const safe = sanitize( userName || (hasUid ? ('Pixiv_' + uidStr) : 'Pixiv_Unknown') ); const same = all.find(f => (f.folderName || f.name) === safe); if (same) { if (hasUid) { try { await updateFolderDesc(same.id, `pid = ${uidStr}`); } catch { } } return same.id; } const id = await createFolder(safe, parentId); if (hasUid) { try { await updateFolderDesc(id, `pid = ${uidStr}`); } catch { } } return id; } /******************** 勾选框(同 0.9.5.4) ********************/ let lastChecked = null; function addCheck(a) { const m = a.href.match(/artworks\/(\d+)/); if (!m) return; const id = m[1]; if (document.querySelector(`.pxe-mini-checkbox[data-id="${id}"]`)) return; let host = a.closest('div[role="listitem"], div[data-testid], figure, li, article, a'); if (!host) host = a.parentElement || a; function findPositionedAncestor(el) { let p = el; while (p && p !== document.body) { const pos = getComputedStyle(p).position; if (pos && pos !== 'static') return p; p = p.parentElement; } return null; } const container = findPositionedAncestor(host) || host; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'pxe-mini-checkbox'; cb.dataset.id = id; Object.assign(cb.style, { position: 'absolute', top: '6px', left: '6px', zIndex: 2147483001, width: '18px', height: '18px', accentColor: '#409EFF', cursor: 'pointer' }); cb.addEventListener('click', (e) => { e.stopPropagation(); if (e.shiftKey && lastChecked) { const all = Array.from(new Map(Array.from(document.querySelectorAll('.pxe-mini-checkbox')).map(x => [x.dataset.id, x])).values()); const i1 = all.indexOf(lastChecked), i2 = all.indexOf(cb); const [s, e2] = [Math.min(i1, i2), Math.max(i1, i2)]; for (let i = s; i <= e2; i++) all[i].checked = cb.checked; } lastChecked = cb.checked ? cb : null; }); container.appendChild(cb); } function scan() { document.querySelectorAll('a[href*="/artworks/"]:not([data-pxe-mini])').forEach(a => { a.dataset.pxeMini = 1; addCheck(a); }); } function watch() { scan(); if (!watch._mo) { watch._mo = new MutationObserver(m => { if (m.some(x => x.addedNodes.length)) scan(); }); watch._mo.observe(document.body, { childList: true, subtree: true }); } } /******************** 进度条盒子 & UI ********************/ let cancel = false, t0 = 0, bigGifFallbacks = []; function box(id, title) { const w = document.createElement('div'); w.id = id; Object.assign(w.style, { position: 'fixed', top: '14px', right: '14px', zIndex: 2147483000 }); w.innerHTML = `<div style="width:334px;padding:8px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.18);background:#fff;font-size:12px;"> <div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;"> <div id="${id}-left" style="display:flex;align-items:center;gap:6px;"> <div style="font-weight:600;color:#333;white-space:nowrap;">${title}</div> <button id="${id}-led" title="检查 Eagle (点击重载工具条)" style="border:none;background:transparent;padding:0;cursor:pointer;line-height:1;">●</button> </div> <div id="${id}-eta" style="margin-left:6px;color:#888;font-size:12px;"></div> <button id="${id}-close" style="margin-left:auto;padding:2px 6px;border:none;background:#909399;color:#fff;border-radius:4px;cursor:pointer;">关闭</button> </div> <div style="flex:1;border:1px solid #e6e6e6;height:16px;border-radius:4px;overflow:hidden;background:#f5f7fa;margin-bottom:6px;"> <div id="${id}-bar" style="width:0%;height:100%;background:#409eff;color:#fff;text-align:center;line-height:16px;">0%</div> </div> <div style="display:flex;gap:8px;align-items:center;"> <div id="${id}-txt" style="color:#666;"></div> <button id="${id}-cancel" style="margin-left:auto;padding:2px 6px;border:none;background:#f56c6c;color:#fff;border-radius:4px;cursor:pointer;">取消</button> </div> </div>`; document.body.appendChild(w); w.querySelector(`#${id}-close`).onclick = () => w.remove(); w.querySelector(`#${id}-cancel`).onclick = () => { if (cancel) return; cancel = true; cancelInflight(); const b = w.querySelector(`#${id}-bar`); b.style.background = '#f56c6c'; b.textContent = '取消中...'; }; w.querySelector(`#${id}-led`).onclick = () => { document.getElementById('pxeMiniBar')?.remove(); setTimeout(mountBar, 0); checkEagleLed(w.querySelector(`#${id}-led`)); }; checkEagleLed(w.querySelector(`#${id}-led`)); return w; } // 统一的进度更新函数(避免 updScan / updImport 重复) function updateProgress(boxId, { done = 0, total = 0, ok = 0, collectPhase = false } = {}) { const b = document.querySelector(`#${boxId}-bar`); const t = document.querySelector(`#${boxId}-txt`); const e = document.querySelector(`#${boxId}-eta`); if (collectPhase) { if (b) { b.style.width = '0%'; b.textContent = '收集中'; } if (t) { t.textContent = `已找到 ${done} 个作品ID`; } return; } const p = total > 0 ? Math.round(done / total * 100) : 0; if (b) { b.style.width = Math.max(1, p) + '%'; b.textContent = `${p}%`; } if (t) { t.textContent = `${done} / ${total} 作品 (成功:${ok})`; } const dt = (Date.now() - t0) / 1000; // 需要至少 1 秒且 done >= 1 才能计算速度 if (dt >= 1 && done > 0) { const rate = done / dt; const remain = total - done; const eta = rate > 0 ? Math.round(remain / rate) : 0; if (e) { e.textContent = `ETA ${Math.floor(eta / 60)}m${eta % 60}s`; } } else if (e) { // 数据不足,显示占位符 e.textContent = '计算中...'; } } function showScan() { cancel = false; t0 = Date.now(); document.getElementById('pxeScan')?.remove(); const el = box('pxeScan', '扫描作品'); el.querySelector('#pxeScan-txt').textContent = '正在收集作品ID...'; updateProgress('pxeScan', { done: 0, total: 0, collectPhase: true }); } function closeScan() { document.getElementById('pxeScan')?.remove(); } function showImport(total) { cancel = false; t0 = Date.now(); document.getElementById('pxeMiniProg')?.remove(); const el = box('pxeMiniProg', 'PTE'); el.querySelector('#pxeMiniProg-txt').textContent = `0 / ${total} 作品`; } // 向后兼容的旧函数(现在委托给 updateProgress) function updScan(done, total, collectPhase) { updateProgress('pxeScan', { done, total, collectPhase }); } function updImport(done, total, ok = 0) { updateProgress('pxeMiniProg', { done, total, ok }); if (done === total && !cancel) { setTimeout(() => document.getElementById('pxeMiniProg')?.remove(), 1200); } } /******************** Eagle 连接指示 ********************/ async function checkEagle() { try { const r = await xhr({ url: EAGLE.base + EAGLE.api.list }); return !!(r && (r.data || r.folders)); } catch { return false; } } async function checkEagleLed(el) { const ok = await checkEagle(); if (!el) return ok; el.textContent = '●'; el.style.color = ok ? '#10b981' : '#ef4444'; el.title = (ok ? 'Eagle 已连接' : 'Eagle 未连接') + '(点击重载工具条)'; return ok; } /******************** 收集ID ********************/ function collectIdsFromPage() { const anchors = Array.from(document.querySelectorAll('a[href*="/artworks/"]')); return [...new Set(anchors.map(a => a.href.match(/artworks\/(\d+)/)?.[1]).filter(Boolean))]; } /******************** 极简长条 UI(保持 0.9.5.4) ********************/ function isCollapsed() { return !!LS.get('collapsed', false); } function setCollapsed(v, pos) { LS.set('collapsed', !!v); const bar = document.getElementById('pxeMiniBar'); if (!v) { // 还原:优先用当前小圆点中心作为 anchor if (bar) { try { const r = bar.getBoundingClientRect(); const anchor = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; localStorage.setItem(LSKEY + ':anchor', JSON.stringify(anchor)); // 同时把当前左上角写回 barPos,作为还原时的基准 LS.set('barPos', { x: Math.round(r.left), y: Math.round(r.top) }); } catch { } bar.remove(); } // 重新挂载为面板 mountBar(); return; } else { // 缩小:允许传入目标左上角 pos(来自缩小按钮计算),否则保留现有 barPos if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') { LS.set('barPos', { x: Math.floor(pos.x), y: Math.floor(pos.y) }); } if (bar) bar.remove(); mountBar(); return; } } function enableCollapsedDragOrClick(bar, m) { let dragging = false, moved = false, sx = 0, sy = 0; function clamp(x, y, w, h) { const nx = Math.min(window.innerWidth - m - w, Math.max(m, x)); const ny = Math.min(window.innerHeight - m - h, Math.max(m, y)); return { x: nx, y: ny }; } bar.addEventListener('pointerdown', (ev) => { dragging = true; moved = false; sx = ev.clientX; sy = ev.clientY; try { bar.setPointerCapture(ev.pointerId); } catch { } bar.style.cursor = 'grabbing'; }); bar.addEventListener('pointermove', (ev) => { if (!dragging) return; const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 3) moved = true; const r = bar.getBoundingClientRect(); const w = r.width, h = r.height; const pos = clamp(r.left + dx, r.top + dy, w, h); bar.style.left = pos.x + 'px'; bar.style.top = pos.y + 'px'; sx = ev.clientX; sy = ev.clientY; }); function finish(ev) { if (!dragging) return; dragging = false; bar.style.cursor = 'grab'; try { const r = bar.getBoundingClientRect(); localStorage.setItem(LSKEY + ':barPos', JSON.stringify({ x: Math.round(r.left), y: Math.round(r.top) })); } catch { } if (!moved) { // 视为点击:展开面板 setCollapsed(false); } try { bar.releasePointerCapture(ev.pointerId); } catch { } } bar.addEventListener('pointerup', finish); bar.addEventListener('pointercancel', finish); } /** 拖动整块面板(非最小化状态)。handleEl 存在时,只允许拖动 handleEl 区域 */ function enableDrag(box, margin, handleEl) { const target = handleEl || box; let dragging = false, sx = 0, sy = 0; function clamp(x, y, w, h) { const nx = Math.min(window.innerWidth - margin - w, Math.max(margin, x)); const ny = Math.min(window.innerHeight - margin - h, Math.max(margin, y)); return { x: nx, y: ny }; } target.addEventListener('pointerdown', (ev) => { // 只允许左键 / 触摸 if (ev.button !== undefined && ev.button !== 0) return; dragging = true; try { target.setPointerCapture(ev.pointerId); } catch { } const r = box.getBoundingClientRect(); sx = ev.clientX - r.left; sy = ev.clientY - r.top; document.body.style.userSelect = 'none'; }); target.addEventListener('pointermove', (ev) => { if (!dragging) return; const r = box.getBoundingClientRect(); const { x, y } = clamp(ev.clientX - sx, ev.clientY - sy, r.width, r.height); box.style.left = x + 'px'; box.style.top = y + 'px'; }); function finish(ev) { if (!dragging) return; dragging = false; try { target.releasePointerCapture(ev.pointerId); } catch { } document.body.style.userSelect = ''; try { const r = box.getBoundingClientRect(); localStorage.setItem(LSKEY + ':barPos', JSON.stringify({ x: Math.round(r.left), y: Math.round(r.top) })); } catch { } } target.addEventListener('pointerup', finish); target.addEventListener('pointercancel', finish); } function mountBar() { if (document.getElementById('pxeMiniBar')) return; const m = CFG.ui.margin; const pos = LS.get('barPos', { x: CFG.ui.x, y: CFG.ui.y }); const bar = document.createElement('div'); bar.id = 'pxeMiniBar'; document.body.appendChild(bar); const colW = 32, gapX = 10, pad = 10, cols = 3; const fixedW = cols * colW + (cols - 1) * gapX + pad * 2; if (isCollapsed()) { Object.assign(bar.style, { position: 'fixed', zIndex: 2147483647, left: pos.x + 'px', top: pos.y + 'px', width: '40px', height: '40px', borderRadius: '999px', background: '#409eff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: '700', fontSize: '16px', boxShadow: '0 6px 22px rgba(0,0,0,.12)', userSelect: 'none', cursor: 'grab' }); bar.style.background = (CFG.mode === 'disk' ? COLOR.disk : COLOR.eagle); bar.textContent = (CFG.mode === 'disk' ? 'D' : 'E'); bar.title = '展开 (单击) / 拖动 (移动位置)'; enableCollapsedDragOrClick(bar, m); return; } Object.assign(bar.style, { position: 'fixed', zIndex: 2147483647, left: pos.x + 'px', top: pos.y + 'px', background: 'rgba(255,255,255,0.96)', border: '1px solid rgba(0,0,0,.08)', borderRadius: '12px', boxShadow: '0 6px 22px rgba(0,0,0,.12)', boxSizing: 'border-box', padding: `8px ${pad}px`, overflow: 'hidden', userSelect: 'none', width: fixedW + 'px', maxWidth: `calc(100vw - ${m * 2}px)` }); // 顶部:标题(蓝色粗体 PTE) + 绿灯 + 时钟 + D/E + 缩小 const topRow = document.createElement('div'); Object.assign(topRow.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }); const title = document.createElement('div'); title.textContent = 'PTE'; title.style.cssText = 'font-size:12px;cursor:move;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:700;color:#1f6fff;flex-shrink:0;'; // 中间容器:绿灯、时钟、D/E 平均分配 const middleGroup = document.createElement('div'); Object.assign(middleGroup.style, { display: 'flex', alignItems: 'center', gap: '4px', flex: '1', justifyContent: 'space-around', minWidth: '0' }); const led = document.createElement('button'); led.textContent = '●'; led.title = '检查 Eagle (点击重载工具条)'; led.style.cssText = 'border:none;background:transparent;padding:0;cursor:pointer;line-height:1;color:#10b981;font-size:12px;flex:0 1 auto;'; led.onclick = () => { const r = bar.getBoundingClientRect(); LS.set('barPos', { x: r.left, y: r.top }); bar.remove(); setTimeout(mountBar, 0); }; checkEagleLed(led); // 顶部模式指示:显示 'E' 或 'D',仅用字体颜色区分;点击可切换 const modeMark = document.createElement('button'); modeMark.setAttribute('data-pxe-mode-mark', '1'); function updateModeMark() { const disk = (CFG.mode === 'disk'); modeMark.textContent = disk ? 'D' : 'E'; modeMark.title = disk ? '本地模式(点击切换)' : 'Eagle 模式(点击切换)'; modeMark.style.cssText = 'border:none;background:transparent;padding:0;width:16px;height:18px;' + 'font-size:12px;font-weight:700;cursor:pointer;line-height:18px;text-align:center;flex:0 1 auto;' + 'color:' + (disk ? COLOR.disk : COLOR.eagle) + ';'; } updateModeMark(); modeMark.onclick = () => { CFG.mode = (CFG.mode === 'disk' ? 'eagle' : 'disk'); try { LS.set('mode', CFG.mode); } catch { } updateModeMark(); render(); }; // 顶部时钟(仅在开启时显示;点击即可关闭并消失) const topClockBox = document.createElement('span'); function updateTopClock() { // 顶部工具条始终显示“投稿时间→添加日期”开关 const on = !!CFG.feature.useUploadAsAddDate; try { topClockBox.style.display = 'inline-block'; topClockBox.textContent = '🕒'; topClockBox.title = on ? '投稿时间→添加日期:已启用(点击关闭)' : '投稿时间→添加日期:未启用(点击开启)'; topClockBox.style.cssText = [ 'cursor:pointer', 'font-size:12px', 'line-height:1', 'padding:0 2px', 'flex:0 1 auto', on ? 'filter:none' : 'filter:grayscale(100%) opacity(0.55)' ].join(';'); topClockBox.onclick = () => { CFG.feature.useUploadAsAddDate = !CFG.feature.useUploadAsAddDate; try { LS.set('useUploadAsAddDate', CFG.feature.useUploadAsAddDate); } catch (e) { } updateTopClock(); try { render && render(); } catch (e) { } }; } catch (e) { // 若顶部容器不存在,降级隐藏 try { topClockBox.style.display = 'none'; topClockBox.textContent = ''; topClockBox.removeAttribute('title'); topClockBox.onclick = null; } catch (_) { } } } // 初始渲染一次 updateTopClock(); // 监听 LS.set,以便在第二页切换时同步顶部图标 try { const _LSset = LS.set.bind(LS); LS.set = (k, v) => { _LSset(k, v); if (k === 'useUploadAsAddDate') { try { updateTopClock(); } catch (e) { } } }; } catch (e) { } const shrink = document.createElement('button'); shrink.textContent = '➖'; shrink.title = '缩小'; shrink.style.cssText = 'padding:0 4px;height:20px;border:none;background:transparent;color:#6b7280;border-radius:4px;cursor:pointer;font-size:16px;flex-shrink:0;'; shrink.onclick = () => { const sr = shrink.getBoundingClientRect(); const size = 40; const m = CFG.ui.margin; let x = sr.right - size; let y = sr.top - Math.max(0, (size - sr.height) / 2); x = Math.min(window.innerWidth - m - size, Math.max(m, x)); y = Math.min(window.innerHeight - m - size, Math.max(m, y)); try { localStorage.setItem(LSKEY + ':anchor', JSON.stringify({ x: x + size / 2, y: y + size / 2 })); } catch { } setCollapsed(true, { x: Math.floor(x), y: Math.floor(y) }); }; middleGroup.append(led, topClockBox, modeMark); topRow.append(title, middleGroup, shrink); bar.appendChild(topRow); // 用 anchor(小圆点中心) 来精确对齐缩小按钮: // 计算缩小按钮相对整个面板的中心偏移,然后把面板左上角设置为 anchor - 偏移 try { const anchorRaw = localStorage.getItem(LSKEY + ':anchor'); if (anchorRaw) { const anchor = JSON.parse(anchorRaw); const br = bar.getBoundingClientRect(); const sr = shrink.getBoundingClientRect(); const relX = (sr.left - br.left) + sr.width / 2; const relY = (sr.top - br.top) + sr.height / 2; let nx = Math.round(anchor.x - relX); let ny = Math.round(anchor.y - relY); const m = CFG.ui.margin; const vw = window.innerWidth, vh = window.innerHeight; // 夹取,保证面板完整可见 nx = Math.max(m, Math.min(vw - m - br.width, nx)); ny = Math.max(m, Math.min(vh - m - br.height, ny)); bar.style.left = nx + 'px'; bar.style.top = ny + 'px'; try { localStorage.setItem(LSKEY + ':barPos', JSON.stringify({ x: nx, y: ny })); } catch { } try { localStorage.removeItem(LSKEY + ':anchor'); } catch { } } } catch { } // 网格按钮 const grid = document.createElement('div'); Object.assign(grid.style, { display: 'grid', gridTemplateColumns: 'repeat(3, 32px)', justifyContent: 'start', justifyItems: 'center', gap: '6px 10px', alignItems: 'center' }); bar.appendChild(grid); grid.style.gridAutoRows = '28px'; // 统一按钮尺寸 & 顶部模式同步 const BTN = 40; // 与第一页一致(如需调整,改这里即可) function syncModeMark() { const el = document.querySelector('[data-pxe-mode-mark="1"]'); if (!el) return; const disk = (CFG.mode === 'disk'); el.textContent = disk ? 'D' : 'E'; el.title = disk ? '本地模式(点击切换)' : 'Eagle 模式(点击切换)'; el.style.color = disk ? COLOR.disk : COLOR.eagle; } function iconBtn(emoji, tip, onClick, opts = {}) { const b = document.createElement('button'); b.textContent = emoji; b.title = tip; const bg = opts.bg || '#409eff'; b.style.cssText = `width:32px;height:28px;margin:0;box-sizing:border-box;padding:0;border:none;background:${bg};border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.06);cursor:pointer;font-size:16px;line-height:28px;text-align:center;text-align:center;text-align:center;text-align:center;text-align:center;`; b.onclick = onClick; return b; } function spacer() { const b = document.createElement('button'); b.title = ''; b.disabled = true; b.style.cssText = `width:${BTN}px;height:${BTN}px;padding:0;border:none;background:transparent;border-radius:8px;opacity:0;pointer-events:none;`; return b; } function invertSelection() { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = !cb.checked; }); } const onArtwork = isArtwork(); const onUserPage = isUser(); const state = { page: 1 }; const render = () => { grid.innerHTML = ''; if (state.page === 1) { if (onUserPage) { grid.append( iconBtn('🌐', '作者全部', () => importMode('all')), iconBtn('📄', '本页', () => importMode('page')), iconBtn('✅', '仅勾选', () => importMode('selected')), iconBtn('☑️', '全选', () => { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = true; }); }), iconBtn('◻️', '全不选', () => { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = false; }); }), iconBtn('➡️', '下一页', () => { state.page = 2; render(); }) ); } else if (onArtwork) { // 详情页:六键布局,顺序:此作 | 本页 | 仅勾选 | 全选 | 全不选 | 下一页 grid.append( iconBtn('🎯', '此作', () => importMode('one')), iconBtn('📄', '本页', () => importMode('page')), iconBtn('✅', '仅勾选', () => importMode('selected')), iconBtn('☑️', '全选', () => { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = true; }); }), iconBtn('◻️', '全不选', () => { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = false; }); }), iconBtn('➡️', '下一页', () => { state.page = 2; render(); }) ); } else { grid.append( iconBtn('🌐', '本页全部', () => importMode('page')), iconBtn('📄', '本页', () => importMode('page')), iconBtn('✅', '仅勾选', () => importMode('selected')), iconBtn('☑️', '全选', () => { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = true; }); }), iconBtn('◻️', '全不选', () => { document.querySelectorAll('.pxe-mini-checkbox').forEach(cb => { cb.checked = false; }); }), iconBtn('➡️', '下一页', () => { state.page = 2; render(); }) ); } } else { // 第二页:反选 + 模式切换(E/D) + 选择下载目录 + 公告按钮 + 上一页(已将“投稿时间→添加日期”移动到顶部工具条) const btnInvert = iconBtn('🔁', '反选', invertSelection); const btnTagManager = iconBtn('🏷️', '标签管理', async () => { await createTagManagerModal(); }); const btnPick = iconBtn('📁', '选择下载目录', async () => { await ptePickDownloadsRoot(); }, { bg: '#f1a72e' }); const btnNotice = iconBtn('📜', '公告', () => { createWelcomeModal(Date.now()); }); const btnBack = iconBtn('⬅️', '上一页', () => { state.page = 1; render(); }); try { grid.style.gridTemplateColumns = 'repeat(3, 32px)'; btnTagManager.style.gridColumn = '2'; btnTagManager.style.gridRow = '1'; btnPick.style.gridColumn = '1'; btnPick.style.gridRow = '2'; btnNotice.style.gridColumn = '2'; btnNotice.style.gridRow = '2'; btnBack.style.gridColumn = '3'; btnBack.style.gridRow = '2'; } catch (e) { } grid.append(btnInvert, btnTagManager, spacer(), btnPick, btnNotice, btnBack); } }; render(); enableDrag(bar, m, title); } watch(); setTimeout(mountBar, 0); try { // Dynamic update date from script install time (YYYY-MM-DD) var PTE_UPDATED_DATE = '2025-11-19'; try { if (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.installed) { PTE_UPDATED_DATE = new Date(GM_info.script.installed).toISOString().split('T')[0]; } } catch (e) { } // Use existing LS helper if available; otherwise namespaced localStorage shim var _LS = (typeof LS !== 'undefined' && LS && typeof LS.get === 'function') ? LS : { get: function (k, d) { try { var v = localStorage.getItem('pxeMini:' + k); return v !== null ? JSON.parse(v) : d; } catch (e) { return d; } }, set: function (k, v) { try { localStorage.setItem('pxeMini:' + k, JSON.stringify(v)); } catch (e) { } } }; function fmtTime(ts) { try { return new Date(ts).toLocaleString('zh-CN', { hour12: false }); } catch (e) { return '' + ts; } } function createWelcomeModal(updatedAtTs) { if (document.getElementById('pteWelcome')) return; var mask = document.createElement('div'); mask.id = 'pteWelcome'; Object.assign(mask.style, { position: 'fixed', inset: '0', background: 'rgba(0,0,0,.35)', backdropFilter: 'blur(2px)', zIndex: 2147483647, display: 'flex', alignItems: 'center', justifyContent: 'center' }); var box = document.createElement('div'); Object.assign(box.style, { width: 'min(560px,92vw)', borderRadius: '16px', background: '#fff', boxShadow: '0 12px 40px rgba(0,0,0,.18)', padding: '16px 18px', fontSize: '13px', color: '#444', lineHeight: '1.6', maxHeight: '80vh', overflow: 'auto' }); var timeStr = PTE_UPDATED_DATE; box.innerHTML = '' + '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">' + '<div style="font-size:18px;font-weight:700;color:#1f6fff;">PTE 已更新 ✅</div>' + '<span style="margin-left:auto;color:#999;font-size:12px">v' + PTE_VER + '</span>' + '</div>' + '<div style="color:#999;font-size:12px;margin-bottom:8px;">更新时间:' + timeStr + ' | 版本号:v' + PTE_VER + '</div>' + '<div>' + '<p>右上角工具条:<b style="color:#409eff">E(蓝)</b> = Eagle 模式,<b style="color:#f1a72e">D(橙)</b> = 本地模式。</p>' + '<p>详情页六键:<code>此作</code> / <code>本页</code> / <code>仅勾选</code> / <code>全选</code> / <code>全不选</code> / <code>下一页</code>。</p><p>顶部工具条新增并固定“🕒 投稿时间→添加日期”开关(点击切换;关闭时灰度显示)。</p>' + '<p>第二页:🔁 反选 · 📁 选择下载目录(左下) · 📜 公告 · ⬅️ 上一页(右下)。</p>' + '<p><b style="color:#ff4d4f">大动图说明:</b> 当 ugoira→GIF 体积过大(约 >40MB)时,脚本会自动从 Eagle 模式切换为“保存到本地”模式,并保存到下载目录下的 <code>Pixiv/作者名_作者ID/作品ID.gif</code>,以避免浏览器 / 油猴在导入 Eagle 时因消息过长而卡住。</p>' + '<p style="color:#666">小技巧:点击绿灯检查 Eagle;点“➖”可缩小为悬浮圆点。</p>' + '<p style="margin-top:6px"><b>没看到弹窗/工具条?</b> 如果脚本已启动但首次没看到,UI 可能在浏览器窗口右侧;请尝试将浏览器窗口<b>拉宽</b>即可看见。</p>' + '<p><b>连续多选:</b> 在列表/缩略图页,先点击左侧的勾选框选中一项,然后按住 <kbd>Shift</kbd> 再点击另一项,<b>两者之间的范围</b>会被一次性选中。</p>' + '</div>' + '<div style="display:flex;gap:10px;margin-top:14px;justify-content:flex-end;">' + '<button id="pxeWelcomeOk" style="padding:6px 14px;border:none;border-radius:8px;background:#409eff;color:#fff;cursor:pointer;font-weight:600">我知道了</button>' + '</div>'; mask.appendChild(box); document.body.appendChild(mask); mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); var ok = box.querySelector('#pxeWelcomeOk'); if (ok) ok.addEventListener('click', function () { mask.remove(); }); } function showWelcomePerVersion() { var hasShown = _LS.get('welcomeShownOnce', false); // Only show welcome modal once in the user's lifetime if (!hasShown) { var now = Date.now(); _LS.set('welcomeAt', now); _LS.set('welcomeVer', PTE_VER); _LS.set('welcomeShownOnce', true); // Show after DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { setTimeout(function () { createWelcomeModal(now); }, 200); }, { once: true }); } else { setTimeout(function () { createWelcomeModal(now); }, 200); } } } // Schedule after the script's own UI mounts; using a slight delay avoids racing existing layout code setTimeout(showWelcomePerVersion, 600); } catch (e) { /* silent */ } })(); /* === /PTE Welcome Modal (auto-insert) === */