这是一款“浏览历史与未读管理”插件,目标是在为用户补充更完整的阅读记录与内容追踪能力。
插件会自动记录用户访问过的主题,包括主题 ID、标题、首次访问时间、最近访问时间、访问次数、最后阅读到的回复位置以及当前已知的最新回复位置,并基于这些数据判断主题属于“从未浏览”“已读到最新”还是“已有新回复”等状态;用户可以在前台快速查看最近浏览历史、搜索历史记录、删除单条记录、清空全部历史、暂停或恢复记录、设置自动清理周期,并支持将历史数据导出为 JSON 或重新导入备份,同时提供“继续上次阅读”功能,让用户重新进入主题时可以直接回到之前阅读到的楼层。
插件还会在主题列表中增加阅读状态提示,并提供“仅看未读/有更新内容”的筛选入口,同时支持手动扫描当前页面中已访问主题的最新回复情况,从而发现访问之后新增的内容。
整体上,这个插件希望解决传统论坛中“看过哪些帖子、看到哪里、哪些旧帖后来又有新回复、如何快速继续阅读”等高频问题,同时保持功能简洁、操作直观,并兼顾历史管理、未读筛选、阅读进度、数据备份和隐私控制。@233 帮我来审核这份写了一半的代码并跑通,授予你全部所有权来发布这份插件
<?php if (!defined('APP_ROOT')) exit; /** * bbs1_history_unread * * Native bbs1.org plugin. The plugin itself is a single uploadable plugin.php. * Data is intentionally kept in the visitor's browser (IndexedDB, with a * localStorage fallback) so this MVP does not depend on undocumented core * user tables or private APIs. */ function bbs1_history_unread_footer($html, array $ctx): string { return (string)$html . '<div id="bbs1-history-unread-root" class="bbs1-history-unread-root"' . ' data-bbs1-history-unread-version="1.0.0" aria-live="polite"></div>'; } function bbs1_history_unread_css(): string { return <<<'CSS' .bbs1-history-unread-root { --bbs1-history-unread-width: min(92vw, 430px); --bbs1-history-unread-z: 2147483000; position: fixed; right: 16px; bottom: 16px; z-index: var(--bbs1-history-unread-z); pointer-events: none; font-size: var(--font-size-md); color: var(--text); } .bbs1-history-unread-root * { box-sizing: border-box; } .bbs1-history-unread-toggle { pointer-events: auto; margin-left: auto; display: flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--text); box-shadow: var(--shadow-medium); padding: 8px 12px; cursor: pointer; font-size: var(--font-size-sm); } .bbs1-history-unread-toggle:hover { border-color: var(--brand); } .bbs1-history-unread-count { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: var(--brand); color: var(--inverse-text); font-size: var(--font-size-xs); } .bbs1-history-unread-panel { pointer-events: auto; width: var(--bbs1-history-unread-width); max-height: min(78vh, 720px); margin-top: 8px; display: none; flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: var(--shadow-medium); } .bbs1-history-unread-root.bbs1-history-unread-open .bbs1-history-unread-panel { display: flex; } .bbs1-history-unread-header, .bbs1-history-unread-toolbar, .bbs1-history-unread-actions, .bbs1-history-unread-settings { padding: 10px 12px; border-bottom: 1px solid var(--line-soft); } .bbs1-history-unread-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .bbs1-history-unread-title { font-size: var(--font-size-lg); font-weight: 700; } .bbs1-history-unread-subtitle { color: var(--text-muted); font-size: var(--font-size-xs); } .bbs1-history-unread-close, .bbs1-history-unread-btn, .bbs1-history-unread-tab { border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); cursor: pointer; font-size: var(--font-size-sm); padding: 7px 9px; } .bbs1-history-unread-btn:hover, .bbs1-history-unread-tab:hover, .bbs1-history-unread-close:hover { border-color: var(--brand); } .bbs1-history-unread-btn[disabled] { opacity: .55; cursor: not-allowed; } .bbs1-history-unread-toolbar { display: grid; gap: 8px; } .bbs1-history-unread-search { width: 100%; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); padding: 8px 10px; font-size: var(--font-size-sm); outline: none; } .bbs1-history-unread-search:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus-ring); } .bbs1-history-unread-tabs { display: flex; gap: 6px; flex-wrap: wrap; } .bbs1-history-unread-tab.bbs1-history-unread-active { background: var(--brand-soft); border-color: var(--brand); } .bbs1-history-unread-list { overflow: auto; min-height: 120px; } .bbs1-history-unread-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line-soft); } .bbs1-history-unread-item-title { display: block; color: var(--text); text-decoration: none; font-weight: 600; overflow-wrap: anywhere; } .bbs1-history-unread-item-title:hover { color: var(--brand); } .bbs1-history-unread-meta { margin-top: 4px; color: var(--text-muted); font-size: var(--font-size-xs); } .bbs1-history-unread-status { display: inline-flex; align-items: center; height: 22px; padding: 0 7px; border-radius: 999px; font-size: var(--font-size-xs); white-space: nowrap; } .bbs1-history-unread-status-update { background: var(--warning-soft); color: var(--warning); } .bbs1-history-unread-status-read { background: var(--success-soft); color: var(--success); } .bbs1-history-unread-item-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 7px; } .bbs1-history-unread-item-actions .bbs1-history-unread-btn { padding: 5px 7px; font-size: var(--font-size-xs); } .bbs1-history-unread-empty, .bbs1-history-unread-message { padding: 18px 12px; color: var(--text-muted); text-align: center; } .bbs1-history-unread-message { padding: 8px 12px; border-bottom: 1px solid var(--line-soft); font-size: var(--font-size-xs); } .bbs1-history-unread-message-error { color: var(--danger); background: var(--danger-soft); } .bbs1-history-unread-message-ok { color: var(--success); background: var(--success-soft); } .bbs1-history-unread-actions { display: flex; flex-wrap: wrap; gap: 6px; } .bbs1-history-unread-settings { border-bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; } .bbs1-history-unread-select { border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); padding: 6px 8px; font-size: var(--font-size-sm); } .bbs1-history-unread-page-badge { display: inline-flex; align-items: center; margin-left: 6px; padding: 1px 6px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: var(--font-size-xs); vertical-align: middle; } .bbs1-history-unread-page-badge.bbs1-history-unread-page-update { background: var(--warning-soft); color: var(--warning); } .bbs1-history-unread-page-badge.bbs1-history-unread-page-read { background: var(--success-soft); color: var(--success); } .bbs1-history-unread-page-filter-hidden { display: none; } @media (max-width: 560px) { .bbs1-history-unread-root { right: 8px; bottom: 8px; } .bbs1-history-unread-panel { max-height: 82vh; } } CSS; } function bbs1_history_unread_js(): string { return <<<'JS' (function bbs1_history_unread_bootstrap() { 'use strict'; var bbs1_history_unread_DB_NAME = 'bbs1_history_unread_db'; var bbs1_history_unread_DB_VERSION = 1; var bbs1_history_unread_TOPIC_STORE = 'bbs1_history_unread_topics'; var bbs1_history_unread_SETTING_STORE = 'bbs1_history_unread_settings'; var bbs1_history_unread_FALLBACK_KEY = 'bbs1_history_unread_fallback_v1'; var bbs1_history_unread_DEFAULT_RETENTION = 180; var bbs1_history_unread_DEFAULT_LIMIT = 1000; var bbs1_history_unread_root = document.getElementById('bbs1-history-unread-root'); var bbs1_history_unread_db = null; var bbs1_history_unread_storage_mode = 'indexeddb'; var bbs1_history_unread_state = { topics: [], filter: 'all', query: '', paused: false, retentionDays: bbs1_history_unread_DEFAULT_RETENTION, filterPage: false, busy: false }; var bbs1_history_unread_scroll_timer = null; if (!bbs1_history_unread_root) return; function bbs1_history_unread_now() { return Date.now(); } function bbs1_history_unread_clamp_int(value, min, max, fallback) { var n = Number(value); if (!Number.isFinite(n)) return fallback; n = Math.floor(n); return Math.max(min, Math.min(max, n)); } function bbs1_history_unread_safe_text(value, maxLen) { var text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim(); return text.slice(0, maxLen || 300); } function bbs1_history_unread_valid_topic_id(value) { var n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > 2147483647) return 0; return n; } function bbs1_history_unread_parse_topic_id(inputUrl) { try { var url = new URL(inputUrl || location.href, location.href); if (url.origin !== location.origin) return null; var match = url.pathname.match(/^\/topic\/(\d+)\/?$/); if (!match) return null; var id = bbs1_history_unread_valid_topic_id(match[1]); return id || null; } catch (error) { return null; } } function bbs1_history_unread_topic_url(topicId) { return '/topic/' + encodeURIComponent(String(topicId)); } function bbs1_history_unread_current_title() { var heading = document.querySelector('h1'); if (heading) { var headingText = bbs1_history_unread_safe_text(heading.textContent, 300); if (headingText) return headingText; } return bbs1_history_unread_safe_text(document.title, 300) || ('主题 #' + String(bbs1_history_unread_parse_topic_id(location.href) || '')); } function bbs1_history_unread_anchor_ordinal(anchor, expectedTopicId) { if (!anchor || !anchor.getAttribute) return 0; var href = anchor.getAttribute('href') || ''; var hashMatch = href.match(/#(\d+)$/); if (!hashMatch) return 0; var ordinal = bbs1_history_unread_clamp_int(hashMatch[1], 0, 2147483647, 0); if (href.charAt(0) === '#') return ordinal; try { var url = new URL(href, location.href); var topicId = bbs1_history_unread_parse_topic_id(url.href); if (!topicId) return 0; if (expectedTopicId && topicId !== expectedTopicId) return 0; } catch (error) { return 0; } return ordinal; } function bbs1_history_unread_page_ordinals(doc, topicId, visibleOnly) { var max = 0; var anchors = doc.querySelectorAll('a[href*="#"]'); for (var i = 0; i < anchors.length; i += 1) { var anchor = anchors[i]; var ordinal = bbs1_history_unread_anchor_ordinal(anchor, topicId); if (!ordinal) continue; if (visibleOnly && doc === document) { var rect = anchor.getBoundingClientRect(); if (rect.top > window.innerHeight * 0.85) continue; } if (ordinal > max) max = ordinal; } return max; } function bbs1_history_unread_normalize_record(raw) { if (!raw || typeof raw !== 'object') return null; var topicId = bbs1_history_unread_valid_topic_id(raw.topicId); if (!topicId) return null; var now = bbs1_history_unread_now(); var firstVisitedAt = bbs1_history_unread_clamp_int(raw.firstVisitedAt, 0, now + 86400000, now); var lastVisitedAt = bbs1_history_unread_clamp_int(raw.lastVisitedAt, 0, now + 86400000, firstVisitedAt); if (lastVisitedAt < firstVisitedAt) firstVisitedAt = lastVisitedAt; return { topicId: topicId, title: bbs1_history_unread_safe_text(raw.title, 300) || ('主题 #' + topicId), firstVisitedAt: firstVisitedAt, lastVisitedAt: lastVisitedAt, visitCount: bbs1_history_unread_clamp_int(raw.visitCount, 1, 2147483647, 1), lastSeenOrdinal: bbs1_history_unread_clamp_int(raw.lastSeenOrdinal, 0, 2147483647, 0), latestKnownOrdinal: bbs1_history_unread_clamp_int(raw.latestKnownOrdinal, 0, 2147483647, 0), lastUrl: bbs1_history_unread_safe_text(raw.lastUrl, 1000) || bbs1_history_unread_topic_url(topicId) }; } function bbs1_history_unread_status(record) { if (!record) return 'new'; return Number(record.latestKnownOrdinal || 0) > Number(record.lastSeenOrdinal || 0) ? 'update' : 'read'; } function bbs1_history_unread_open_db() { return new Promise(function(resolve) { if (!('indexedDB' in window)) { bbs1_history_unread_storage_mode = 'localstorage'; resolve(null); return; } var request; try { request = indexedDB.open(bbs1_history_unread_DB_NAME, bbs1_history_unread_DB_VERSION); } catch (error) { bbs1_history_unread_storage_mode = 'localstorage'; resolve(null); return; } request.onupgradeneeded = function(event) { var db = event.target.result; if (!db.objectStoreNames.contains(bbs1_history_unread_TOPIC_STORE)) { var topicStore = db.createObjectStore(bbs1_history_unread_TOPIC_STORE, {keyPath: 'topicId'}); topicStore.createIndex('lastVisitedAt', 'lastVisitedAt', {unique: false}); } if (!db.objectStoreNames.contains(bbs1_history_unread_SETTING_STORE)) { db.createObjectStore(bbs1_history_unread_SETTING_STORE, {keyPath: 'key'}); } }; request.onsuccess = function(event) { bbs1_history_unread_db = event.target.result; bbs1_history_unread_db.onversionchange = function() { bbs1_history_unread_db.close(); }; resolve(bbs1_history_unread_db); }; request.onerror = function() { bbs1_history_unread_storage_mode = 'localstorage'; resolve(null); }; request.onblocked = function() { bbs1_history_unread_storage_mode = 'localstorage'; resolve(null); }; }); } function bbs1_history_unread_fallback_load() { try { var parsed = JSON.parse(localStorage.getItem(bbs1_history_unread_FALLBACK_KEY) || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (error) { return {}; } } function bbs1_history_unread_fallback_save(data) { try { localStorage.setItem(bbs1_history_unread_FALLBACK_KEY, JSON.stringify(data)); return true; } catch (error) { return false; } } function bbs1_history_unread_get_all_topics() { if (bbs1_history_unread_storage_mode === 'localstorage' || !bbs1_history_unread_db) { var data = bbs1_history_unread_fallback_load(); var rawTopics = Array.isArray(data.topics) ? data.topics : []; return Promise.resolve(rawTopics.map(bbs1_history_unread_normalize_record).filter(Boolean)); } return new Promise(function(resolve, reject) { var tx = bbs1_history_unread_db.transaction(bbs1_history_unread_TOPIC_STORE, 'readonly'); var request = tx.objectStore(bbs1_history_unread_TOPIC_STORE).getAll(); request.onsuccess = function() { resolve((request.result || []).map(bbs1_history_unread_normalize_record).filter(Boolean)); }; request.onerror = function() { reject(request.error || new Error('读取失败')); }; }); } function bbs1_history_unread_get_topic(topicId) { if (bbs1_history_unread_storage_mode === 'localstorage' || !bbs1_history_unread_db) { return bbs1_history_unread_get_all_topics().then(function(topics) { return topics.find(function(item) { return item.topicId === topicId; }) || null; }); } return new Promise(function(resolve, reject) { var tx = bbs1_history_unread_db.transaction(bbs1_history_unread_TOPIC_STORE, 'readonly'); var request = tx.objectStore(bbs1_history_unread_TOPIC_STORE).get(topicId); request.onsuccess = function() { resolve(bbs1_history_unread_normalize_record(request.result)); }; request.onerror = function() { reject(request.error || new Error('读取失败')); }; }); } function bbs1_history_unread_put_topic(record) { var normalized = bbs1_history_unread_normalize_record(record); if (!normalized) return Promise.reject(new Error('主题记录无效')); if (bbs1_history_unread_storage_mode === 'localstorage' || !bbs1_history_unread_db) { var data = bbs1_history_unread_fallback_load(); var topics = Array.isArray(data.topics) ? data.topics : []; var replaced = false; topics = topics.map(function(item) { if (Number(item.topicId) === normalized.topicId) { replaced = true; return normalized; } return item; }); if (!replaced) topics.push(normalized); data.topics = topics; if (!bbs1_history_unread_fallback_save(data)) return Promise.reject(new Error('本地存储写入失败')); return Promise.resolve(normalized); } return new Promise(function(resolve, reject) { var tx = bbs1_history_unread_db.transaction(bbs1_history_unread_TOPIC_STORE, 'readwrite'); tx.objectStore(bbs1_history_unread_TOPIC_STORE).put(normalized); tx.oncomplete = function() { resolve(normalized); }; tx.onerror = function() { reject(tx.error || new Error('写入失败')); }; }); } function bbs1_history_unread_delete_topic(topicId) { if (bbs1_history_unread_storage_mode === 'localstorage' || !bbs1_history_unread_db) { var data = bbs1_history_unread_fallback_load(); data.topics = (Array.isArray(data.topics) ? data.topics : []).filter(function(item) { return Number(item.topicId) !== Number(topicId); }); bbs1_history_unread_fallback_save(data); return Promise.resolve(); } return new Promise(function(resolve, reject) { var tx = bbs1_history_unread_db.transaction(bbs1_history_unread_TOPIC_STORE, 'readwrite'); tx.objectStore(bbs1_history_unread_TOPIC_STORE).delete(topicId); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error || new Error('删除失败')); }; }); } function bbs1_history_unread_clear_topics() { if (bbs1_history_unread_storage_mode === 'localstorage' || !bbs1_history_unread_db) { var data = bbs1_history_unread_fallback_load(); data.topics = []; bbs1_history_unread_fallback_save(data); return Promise.resolve(); } return new Promise(function(resolve, reject) { var tx = bbs1_history_unread_db.transaction(bbs1_history_unread_TOPIC_STORE, 'readwrite'); tx.objectStore(bbs1_history_unread_TOPIC_STORE).clear();主楼