feat(widget): 新增文章更新检测与通知组件

- 添加新文章通知组件,通过比对 RSS 源检测新发布的文章并在页面右下角显示通知
- 添加内容差异高亮组件,通过比对本地缓存检测文章内容更新并高亮显示变化部分
- 新增 diff 依赖包用于文本差异比较,同时添加对应的类型定义
- 在布局中引入新组件,支持开发者调试模式
This commit is contained in:
二叉树树
2026-01-25 19:02:57 +08:00
parent bc419d631d
commit a3ea4896fe
5 changed files with 284 additions and 0 deletions

View File

@@ -37,6 +37,7 @@
"astro": "5.7.9",
"astro-expressive-code": "^0.41.3",
"astro-icon": "^1.1.5",
"diff": "^8.0.3",
"katex": "^0.16.22",
"overlayscrollbars": "^2.11.1",
"rehype-autolink-headings": "^7.1.0",
@@ -57,6 +58,7 @@
"@iconify-json/material-symbols-light": "^1.2.49",
"@iconify-json/simple-icons": "^1.2.42",
"@rollup/plugin-yaml": "^4.1.2",
"@types/diff": "^8.0.0",
"@types/markdown-it": "^14.1.2",
"@types/mdast": "^4.0.4",
"@types/sanitize-html": "^2.15.0",

20
pnpm-lock.yaml generated
View File

@@ -76,6 +76,9 @@ importers:
astro-icon:
specifier: ^1.1.5
version: 1.1.5
diff:
specifier: ^8.0.3
version: 8.0.3
katex:
specifier: ^0.16.22
version: 0.16.22
@@ -131,6 +134,9 @@ importers:
'@rollup/plugin-yaml':
specifier: ^4.1.2
version: 4.1.2(rollup@2.79.2)
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
@@ -1728,6 +1734,10 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/diff@8.0.0':
resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==}
deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed.
'@types/estree@0.0.39':
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
@@ -2354,6 +2364,10 @@ packages:
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
engines: {node: '>=0.3.1'}
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
direction@2.0.1:
resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==}
hasBin: true
@@ -6877,6 +6891,10 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/diff@8.0.0':
dependencies:
diff: 8.0.3
'@types/estree@0.0.39': {}
'@types/estree@1.0.7': {}
@@ -7667,6 +7685,8 @@ snapshots:
diff@5.2.0: {}
diff@8.0.3: {}
direction@2.0.1: {}
dlv@1.1.3: {}

View File

@@ -0,0 +1,114 @@
<div id="new-post-notification" class="fixed bottom-4 right-4 z-50 transform translate-y-20 opacity-0 transition-all duration-300 pointer-events-none">
<div class="bg-[var(--card-bg)] border border-[var(--primary)] rounded-xl shadow-lg p-4 max-w-sm relative pointer-events-auto">
<button id="close-notification" class="absolute top-2 right-2 text-black/50 dark:text-white/50 hover:text-[var(--primary)] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="flex items-start gap-3">
<div class="bg-[var(--primary)]/10 p-2 rounded-full shrink-0 text-[var(--primary)]">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
</div>
<div>
<h3 class="font-bold text-black dark:text-white mb-1 transition-colors">发现新文章</h3>
<div id="new-post-list" class="text-sm text-black/80 dark:text-white/80 transition-colors space-y-1"></div>
</div>
</div>
</div>
</div>
<script is:inline>
(async function() {
const STORAGE_KEY = 'blog-posts-cache';
const NOTIFICATION_ID = 'new-post-notification';
const LIST_ID = 'new-post-list';
// Helper to get current RSS items
async function fetchRSS() {
try {
const response = await fetch('/rss.xml');
const text = await response.text();
const parser = new DOMParser();
const xml = parser.parseFromString(text, 'text/xml');
const items = Array.from(xml.querySelectorAll('item'));
return items.map(item => ({
title: item.querySelector('title')?.textContent || '',
link: item.querySelector('link')?.textContent || '',
guid: item.querySelector('guid')?.textContent || item.querySelector('link')?.textContent || '',
pubDate: new Date(item.querySelector('pubDate')?.textContent || '').getTime()
}));
} catch (e) {
console.error('Failed to fetch RSS:', e);
return [];
}
}
// Helper to show notification
function showNotification(newPosts) {
const notification = document.getElementById(NOTIFICATION_ID);
const list = document.getElementById(LIST_ID);
if (!notification || !list) return;
// Show all posts, no limit
let html = '';
newPosts.forEach(post => {
// Added break-words to handle long titles wrapping
html += `<a href="${post.link}" class="block hover:text-[var(--primary)] hover:underline break-words transition-colors text-black dark:text-white mb-2 last:mb-0" target="_blank">• ${post.title}</a>`;
});
list.innerHTML = html;
// Show animation
setTimeout(() => {
notification.classList.remove('translate-y-20', 'opacity-0', 'pointer-events-none');
}, 1000); // Slight delay after page load
// Close handler
document.getElementById('close-notification')?.addEventListener('click', () => {
notification.classList.add('translate-y-20', 'opacity-0', 'pointer-events-none');
});
}
// Main logic
try {
// Developer Mode Check
const urlParams = new URLSearchParams(window.location.search);
const isDevMode = urlParams.has('debug-notification');
if (isDevMode) {
console.log('[Notification] Developer mode active');
const mockPosts = [
{ title: '测试新文章 1', link: '#', guid: 'test-1' },
{ title: '测试新文章 2 - 标题很长很长很长很长很长很长很长很长很长很长很长', link: '#', guid: 'test-2' },
{ title: '测试新文章 3', link: '#', guid: 'test-3' },
{ title: '测试新文章 4 (应该被隐藏)', link: '#', guid: 'test-4' }
];
showNotification(mockPosts);
return; // Skip normal logic
}
const currentPosts = await fetchRSS();
if (currentPosts.length === 0) return;
const storedData = localStorage.getItem(STORAGE_KEY);
if (!storedData) {
// First visit: just cache current state
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPosts.map(p => p.guid)));
} else {
// Subsequent visit: compare
const cachedGuids = JSON.parse(storedData); // Array of strings
const newPosts = currentPosts.filter(post => !cachedGuids.includes(post.guid));
// Only notify if we found new posts that are genuinely newer than what we have
if (newPosts.length > 0) {
showNotification(newPosts);
}
// Update cache with current state
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPosts.map(p => p.guid)));
}
} catch (e) {
console.error('New post check failed:', e);
}
})();
</script>

View File

@@ -0,0 +1,146 @@
<div id="post-update-notification" class="fixed top-20 right-4 z-50 transform translate-x-full opacity-0 transition-all duration-300 pointer-events-none">
<div class="bg-[var(--card-bg)] border border-[var(--admonitions-color-tip)] rounded-xl shadow-lg p-4 max-w-sm relative pointer-events-auto flex flex-col gap-2">
<div class="flex items-center gap-2 text-[var(--admonitions-color-tip)]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="12" y1="18" x2="12" y2="12"></line><line x1="9" y1="15" x2="15" y2="15"></line></svg>
<span class="font-bold text-sm">内容已更新</span>
</div>
<p class="text-xs text-[var(--text-main)] opacity-80">检测到文章内容有变化,已为您高亮差异部分。</p>
<div class="flex gap-2 mt-1">
<button id="scroll-to-diff" class="px-3 py-1 bg-[var(--admonitions-color-tip)] text-white rounded text-xs hover:opacity-90 transition-opacity">
跳转到更新处
</button>
<button id="close-diff-toast" class="px-3 py-1 bg-transparent border border-[var(--line-divider)] text-[var(--text-main)] rounded text-xs hover:bg-[var(--btn-regular-bg-hover)] transition-colors">
忽略
</button>
</div>
</div>
</div>
<script>
import * as Diff from 'diff';
// Helper to get pure text content from markdown container
function getContentText() {
const container = document.querySelector('.markdown-content');
return container ? container.textContent || '' : '';
}
// Helper to store content hash/text
const STORAGE_PREFIX = 'post-content-cache-';
async function initDiffCheck() {
// Only run on post pages
const postContainer = document.getElementById('post-container');
if (!postContainer) return;
const currentPath = window.location.pathname;
const storageKey = STORAGE_PREFIX + currentPath;
const currentText = getContentText();
// Developer Mode Check
const urlParams = new URLSearchParams(window.location.search);
const isDevMode = urlParams.has('debug-diff');
if (isDevMode) {
console.log('[Diff] Developer mode active');
// Mock old text by removing a random paragraph or adding dummy text
const paragraphs = currentText.split('\n\n');
// Simulate that the middle paragraph is new
const middleIndex = Math.floor(paragraphs.length / 2);
const oldText = paragraphs.filter((_, i) => i !== middleIndex).join('\n\n');
highlightDiff(oldText, currentText);
return;
}
const cachedText = localStorage.getItem(storageKey);
if (!cachedText) {
// First visit, cache current text
localStorage.setItem(storageKey, currentText);
} else if (cachedText !== currentText) {
// Content changed
console.log('[Diff] Content change detected');
highlightDiff(cachedText, currentText);
// Update cache
localStorage.setItem(storageKey, currentText);
}
}
function highlightDiff(oldText, newText) {
const diff = Diff.diffWords(oldText, newText);
const container = document.querySelector('.markdown-content');
if (!container) return;
// We can't easily replace the HTML because it would break structure/components/images
// Strategy: Find the first significant added text chunk and scroll to it/highlight it via a Range/Mark
// But since we are in a static site, the DOM structure might have changed significantly.
// A safer visual approach for this specific request "highlight updates":
// Since we cannot reliably reconstruct the HTML from the Diff output without a complex parser,
// we will try to match the text node in the DOM.
// Filter for added parts that are significant (e.g., > 10 chars) to avoid noise
const addedParts = diff.filter(part => part.added && part.value.trim().length > 10);
if (addedParts.length === 0) return;
// Show Notification
const notification = document.getElementById('post-update-notification');
if (notification) {
notification.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
document.getElementById('close-diff-toast')?.addEventListener('click', () => {
notification.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none');
});
document.getElementById('scroll-to-diff')?.addEventListener('click', () => {
scrollToFirstDiff(addedParts[0].value);
});
}
}
function scrollToFirstDiff(textToFind) {
// Try to find the text node containing this text
// This is a naive implementation; complex markdown structures might split text across nodes
// But for a "jump to" feature it often suffices to find a substring
const container = document.querySelector('.markdown-content');
if (!container) return;
// Clean up search text (remove newlines, extra spaces)
const searchStr = textToFind.trim().substring(0, 50); // Search for the first 50 chars
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
let node;
while (node = walker.nextNode()) {
if (node.textContent && node.textContent.includes(searchStr)) {
// Found it!
const range = document.createRange();
range.selectNodeContents(node);
const rect = range.getBoundingClientRect();
// Highlight
const span = document.createElement('span');
span.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
span.style.transition = 'background-color 1s';
span.className = 'diff-highlight-flash';
// Wrap the text node (careful not to break DOM if possible, but this is a leaf text node)
// For safety, let's just scroll parent into view and add a class
const parent = node.parentElement;
if (parent) {
parent.scrollIntoView({ behavior: 'smooth', block: 'center' });
parent.classList.add('animate-pulse'); // Tailwind pulse
setTimeout(() => parent.classList.remove('animate-pulse'), 2000);
}
return;
}
}
}
// Initialize on load and swup navigation
document.addEventListener('DOMContentLoaded', initDiffCheck);
document.addEventListener('swup:contentReplaced', initDiffCheck);
</script>

View File

@@ -5,6 +5,7 @@ import "@fontsource/roboto/700.css";
import { profileConfig, siteConfig } from "@/config";
import ConfigCarrier from "@components/ConfigCarrier.astro";
import NewPostNotification from "@components/widget/NewPostNotification.astro";
import {
AUTO_MODE,
BANNER_HEIGHT,
@@ -248,6 +249,7 @@ const bannerOffset =
</noscript>
<div id="bg-box"></div>
<ConfigCarrier></ConfigCarrier>
<NewPostNotification></NewPostNotification>
<slot />
<!-- increase the page height during page transition to prevent the scrolling animation from jumping -->