// ==UserScript== // @name 98助手 // @namespace duang_duang // @version 2.0.2 // @description 98tang 隐藏已访问链接,支持拖拽UI、隐藏/透明度/显示切换 // @author q // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @license MIT // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @downloadURL https://update.greasyfork.icu/scripts/560198/98%E5%8A%A9%E6%89%8B.user.js // @updateURL https://update.greasyfork.icu/scripts/560198/98%E5%8A%A9%E6%89%8B.meta.js // ==/UserScript== (function () { 'use strict'; // 配置 const DB_NAME = '98tang_visited_db'; const STORE_NAME = 'visited_links'; const DB_VERSION = 1; const STORAGE_KEY_POS = '98tang_ui_pos'; const STORAGE_KEY_MODE = '98tang_ui_mode'; // 'opacity', 'hidden', 'show' const STORAGE_KEY_MINIMIZED = '98tang_ui_minimized'; // UI 相关变量 const panelId = 'visited-link-panel'; const visitedClass = 'visited-item'; let currentMode = localStorage.getItem(STORAGE_KEY_MODE) || 'opacity'; let hiddenCount = 0; // IndexDB 工具类 const dbTools = { db: null, init: function () { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = (event) => { console.error("Database error: " + event.target.errorCode); reject(event); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: "id" }); } }; request.onsuccess = (event) => { this.db = event.target.result; resolve(this.db); }; }); }, add: function (id) { return new Promise((resolve, reject) => { if (!this.db) return reject("DB not initialized"); const transaction = this.db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); const request = store.put({ id: id, timestamp: new Date().getTime() }); request.onsuccess = () => resolve(true); request.onerror = () => resolve(false); // 忽略错误 }); }, has: function (id) { return new Promise((resolve, reject) => { if (!this.db) return reject("DB not initialized"); const transaction = this.db.transaction([STORE_NAME], "readonly"); const store = transaction.objectStore(STORE_NAME); const request = store.get(id); request.onsuccess = () => resolve(!!request.result); request.onerror = () => resolve(false); }); } }; // UI 工具类 const uiTools = { updateCount: function () { const el = document.getElementById('visited-count'); if (el) el.innerText = hiddenCount; }, applyMode: function (mode) { currentMode = mode; localStorage.setItem(STORAGE_KEY_MODE, mode); // 移除所有旧模式类 const classes = document.body.classList; for (let i = classes.length - 1; i >= 0; i--) { if (classes[i].startsWith('mode-')) { classes.remove(classes[i]); } } document.body.classList.add('mode-' + mode); }, initStyle: function () { const style = document.createElement('style'); style.innerHTML = ` /* 模式样式 */ body.mode-hidden .${visitedClass} { display: none !important; } body.mode-opacity .${visitedClass} { opacity: 0.3; } body.mode-show .${visitedClass} { /* 正常显示 */ } body.mode-strikethrough .${visitedClass} a { text-decoration: line-through !important; } body.mode-opacity-strike .${visitedClass} { opacity: 0.4; } body.mode-opacity-strike .${visitedClass} a { text-decoration: line-through !important; } body.mode-yellow .${visitedClass} a { color: #aaaa00 !important; } /* 面板样式 */ #${panelId} { position: fixed; z-index: 999999; background: rgba(0, 0, 0, 0.75); color: white; padding: 6px; border-radius: 4px; font-size: 12px; font-family: sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,0.3); user-select: none; width: 110px; backdrop-filter: blur(2px); transition: width 0.2s, background 0.2s; } #${panelId}.minimized { width: auto; padding: 4px 8px; background: rgba(0, 0, 0, 0.5); } #${panelId}.minimized .panel-content { display: none; } #${panelId} .panel-header { cursor: move; font-weight: bold; display: flex; justify-content: space-between; align-items: center; } #${panelId}:not(.minimized) .panel-header { border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 4px; margin-bottom: 4px; } #${panelId} .toggle-btn { cursor: pointer; font-size: 14px; line-height: 1; opacity: 0.7; padding: 0 2px; } #${panelId} .toggle-btn:hover { opacity: 1; color: #4CAF50; } #${panelId} select { background: #333; color: white; border: 1px solid #555; border-radius: 3px; padding: 2px; width: 100%; font-size: 11px; margin-bottom: 2px; cursor: pointer; } #${panelId} .stat-row { font-size: 10px; color: #ccc; text-align: right; } `; document.head.appendChild(style); }, initPanel: function () { if (document.getElementById(panelId)) return; const div = document.createElement('div'); div.id = panelId; // 恢复位置 const savedPos = JSON.parse(localStorage.getItem(STORAGE_KEY_POS) || '{"top": "20px", "left": "20px"}'); div.style.top = savedPos.top; div.style.left = savedPos.left; // 恢复折叠状态 const isMinimized = localStorage.getItem(STORAGE_KEY_MINIMIZED) === 'true'; if (isMinimized) div.classList.add('minimized'); div.innerHTML = `
98助手 ${isMinimized ? '+' : '-'}
已阅: 0
`; document.body.appendChild(div); // 绑定事件 const select = div.querySelector('#mode-select'); select.value = currentMode; select.addEventListener('change', (e) => { this.applyMode(e.target.value); }); // 折叠逻辑 const toggleBtn = div.querySelector('.toggle-btn'); const header = div.querySelector('.panel-header'); const toggleMin = () => { div.classList.toggle('minimized'); const isMin = div.classList.contains('minimized'); toggleBtn.innerText = isMin ? '+' : '-'; localStorage.setItem(STORAGE_KEY_MINIMIZED, isMin); }; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); // 防止触发拖拽 toggleMin(); }); header.addEventListener('dblclick', toggleMin); // 初始化当前模式 this.applyMode(currentMode); this.makeDraggable(div); }, makeDraggable: function (element) { const header = element.querySelector('.panel-header'); let isDragging = false; let startX, startY, initialLeft, initialTop; header.addEventListener('mousedown', (e) => { if (e.target.classList.contains('toggle-btn')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = element.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; header.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const newLeft = initialLeft + dx; const newTop = initialTop + dy; element.style.left = `${newLeft}px`; element.style.top = `${newTop}px`; }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; header.style.cursor = 'move'; // 保存位置 localStorage.setItem(STORAGE_KEY_POS, JSON.stringify({ top: element.style.top, left: element.style.left })); } }); } }; // 业务逻辑 const core = { getIdByUrl: (url, key, regex) => { try { let uri = new URL(url); if (uri.href.indexOf(key) > -1) { const match = uri.href.match(regex); if (match) { return match[1]; } } } catch (e) { // 忽略无效URL } return null; }, getId: function (url) { if (!url) { url = document.URL; } var id = this.getIdByUrl(url, "tid=", /tid=(\d+)/); return id; }, // 隐藏列表中的已读项 hideVisited: async function () { // 如果是详情页,不执行隐藏,但要记录ID if (document.URL.indexOf("mod=viewthread") > -1 || document.URL.indexOf("tid=") > -1) { let id = this.getId(document.URL); if (id) { await dbTools.add(id); console.log("已记录当前页面 ID:", id); } return; } // 列表页才初始化完整面板 uiTools.initStyle(); uiTools.initPanel(); // 列表页处理逻辑 if (document.URL.indexOf("forum.php") > -1) { // 首页/板块列表 const rows = document.querySelectorAll("table tr, #waterfall li"); for (let item of rows) { let a = item.querySelector("a.xst") || item.querySelector("a.z") || (item.querySelectorAll("a")[1]); // 排除一些非帖子链接 if (!a || a.href.indexOf("tid=") === -1) continue; let id = this.getId(a.href); if (id) { // 绑定点击事件,点击即记录 a.addEventListener("mousedown", () => { dbTools.add(id); item.classList.add(visitedClass); // 立即标记 hiddenCount++; uiTools.updateCount(); }); // 检查是否已读 const hasVisited = await dbTools.has(id); if (hasVisited) { item.classList.add(visitedClass); hiddenCount++; } } } uiTools.updateCount(); } }, start: async function () { await dbTools.init(); await this.hideVisited(); console.log("98tang 隐藏插件(带UI版)启动完成"); } }; // 启动 setTimeout(() => { // 检查标题是否包含“98堂” if (document.title.indexOf("98堂") === -1) { console.log("标题不包含98堂,插件不运行"); return; } core.start(); }, 500); })();