feat(widget): 新增文章更新通知系统

- 将通知组件重构为可折叠的铃铛图标和面板,支持最小化状态
- 使用 IndexedDB 替代 localStorage 存储文章数据,支持多站点范围隔离
- 添加内容差异对比功能,可查看文章更新的具体变化
- 实现通知状态持久化,未读更新显示红点提示
- 更新站点公告以说明新功能
This commit is contained in:
二叉树树
2026-01-25 20:10:55 +08:00
parent a3ea4896fe
commit 49830ca18d
2 changed files with 425 additions and 60 deletions

View File

@@ -1,26 +1,86 @@
<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 id="new-post-notification" class="fixed bottom-4 right-4 z-50 flex flex-col items-end pointer-events-none">
<!-- Minimized State (Bell Icon) -->
<button id="notification-minimized" class="pointer-events-auto bg-[var(--card-bg)] border border-[var(--primary)] text-[var(--primary)] p-3 rounded-full shadow-lg transform transition-all duration-300 hover:scale-110 active:scale-95 flex items-center justify-center relative group">
<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="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
<span id="notification-dot" class="absolute top-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-[var(--card-bg)] hidden"></span>
</button>
<!-- Expanded State (Panel) -->
<div id="notification-panel" class="pointer-events-auto bg-[var(--card-bg)] border border-[var(--primary)] rounded-xl shadow-lg p-4 max-w-sm w-80 transform translate-y-4 opacity-0 scale-95 origin-bottom-right transition-all duration-300 hidden absolute bottom-14 right-0">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 text-[var(--primary)]">
<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="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>
<h3 class="font-bold text-black dark:text-white">发现新文章</h3>
</div>
<button id="minimize-notification" class="text-black/50 dark:text-white/50 hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--primary)]/10">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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>
<div id="new-post-list" class="text-sm text-black/80 dark:text-white/80 transition-colors space-y-1 max-h-[60vh] overflow-y-auto custom-scrollbar"></div>
</div>
</div>
<script is:inline>
(async function() {
const STORAGE_KEY = 'blog-posts-cache';
<script>
import * as Diff from 'diff';
(async function() {
const DB_NAME = 'fuwari-rss-store';
const DB_VERSION = 2;
const STORE_NAME = 'posts';
const LOCAL_STORAGE_KEY = 'blog-posts-cache';
const NOTIFICATION_ID = 'new-post-notification';
const LIST_ID = 'new-post-list';
// Compute a context-aware ID for the current site/path
const SCOPE_ID = window.location.pathname.split('/')[1] || 'root';
// IndexedDB Helpers
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
// We use 'id' as the keyPath which will be a composite key
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
});
}
function generateId(guid) {
// Create a unique ID combining the scope (pathname root) and the article guid
return `${SCOPE_ID}:${guid}`;
}
function getStoredPosts(db) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function savePosts(db, posts) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
posts.forEach(post => {
// Ensure we save with the scoped ID
const itemToSave = { ...post, id: generateId(post.guid) };
store.put(itemToSave);
});
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Helper to get current RSS items
async function fetchRSS() {
try {
@@ -30,43 +90,260 @@
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()
}));
return items.map(item => {
const title = item.querySelector('title')?.textContent || '';
const link = item.querySelector('link')?.textContent || '';
const guid = item.querySelector('guid')?.textContent || link;
const pubDate = new Date(item.querySelector('pubDate')?.textContent || '').getTime();
const description = item.querySelector('description')?.textContent || '';
// Try to get content from multiple possible sources to ensure we capture it
const contentEncoded = item.getElementsByTagNameNS('http://purl.org/rss/1.0/modules/content/', 'encoded')[0]?.textContent;
const content = contentEncoded ||
item.getElementsByTagName('content:encoded')[0]?.textContent ||
item.querySelector('content')?.textContent || '';
return {
title,
link,
guid,
pubDate,
description,
content // Full content is stored here
};
});
} 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;
// Helper to compute text diff
function computeDiff(oldText, newText) {
if (!oldText || !newText) return null;
// Strip HTML tags for cleaner comparison (optional, but usually better for text posts)
const stripHtml = (html) => {
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
};
const cleanOld = stripHtml(oldText);
const cleanNew = stripHtml(newText);
// Use 'diff' library to compute line-by-line diff
const diffs = Diff.diffLines(cleanOld, cleanNew);
// Filter to only show relevant changes (context)
// We don't want to show the whole file if only one line changed.
// Simple logic: return chunks that are added or removed, plus a bit of context.
// But for simplicity in UI rendering, let's just return the diff structure
// and let the UI render it with colors.
// We only return if there are actual changes.
const hasChanges = diffs.some(part => part.added || part.removed);
if (!hasChanges) return null;
return diffs;
}
// Helper to show notification
function showNotification(newPosts, timestamp, isFresh, initTime) {
const minimizedBtn = document.getElementById('notification-minimized');
const panel = document.getElementById('notification-panel');
const list = document.getElementById(LIST_ID);
const dot = document.getElementById('notification-dot');
const minimizeBtn = document.getElementById('minimize-notification');
if (!minimizedBtn || !panel || !list) return;
// Always show the minimized bell
minimizedBtn.classList.remove('translate-y-20', 'opacity-0');
const initTimeStr = new Date(initTime).toLocaleString();
// Logic for "No updates"
if (newPosts.length === 0) {
list.innerHTML = `<div class="text-center text-gray-500 dark:text-gray-400 py-4">
<p class="text-sm">自上一次访问,暂无文章更新</p>
<p class="text-xs mt-1 opacity-70">检查于: ${new Date(timestamp).toLocaleString()}</p>
<p class="text-xs mt-1 opacity-50">初始化于: ${initTimeStr}</p>
</div>`;
dot?.classList.add('hidden');
// Setup event listeners even if no posts (so it can be opened)
setupEventListeners();
return;
}
// Show timestamp header
const timeStr = new Date(timestamp).toLocaleString();
let html = `
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2 px-1 flex flex-col gap-0.5">
<div>发现更新于: ${timeStr}</div>
<div class="opacity-70">初始化于: ${initTimeStr}</div>
</div>`;
// 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>`;
const isUpdated = post.isUpdated;
const badge = isUpdated
? '<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded ml-2">Updated</span>'
: '<span class="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 px-1.5 py-0.5 rounded ml-2">New</span>';
// Generate Diff View button if updated
let diffButton = '';
const safeId = 'diff-' + post.guid.replace(/[^a-zA-Z0-9-_]/g, '_');
if (isUpdated && post.diff) {
diffButton = `
<button data-diff-toggle="${safeId}" class="ml-auto text-xs text-[var(--primary)] hover:underline focus:outline-none">
View Changes
</button>`;
}
html += `
<div class="mb-2 last:mb-0">
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-[var(--primary)]/5 transition-colors">
<a href="${post.link}" class="font-medium truncate pr-2 hover:text-[var(--primary)] transition-colors text-black dark:text-white block flex-1" target="_blank">
${post.title}
</a>
<div class="flex items-center shrink-0">
${diffButton}
${badge}
</div>
</div>
${isUpdated && post.diff ? `
<div id="${safeId}" class="hidden mt-2 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 max-h-60 overflow-y-auto">
${post.diff.map(part => {
const colorClass = part.added ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 block my-1 p-1 rounded' :
part.removed ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 block my-1 p-1 rounded' :
'text-gray-500 dark:text-gray-400 block my-1 p-1';
return `<div class="${colorClass}">${part.value}</div>`;
}).join('')}
</div>
` : ''}
</div>`;
});
list.innerHTML = html;
// Show animation
setTimeout(() => {
notification.classList.remove('translate-y-20', 'opacity-0', 'pointer-events-none');
}, 1000); // Slight delay after page load
// Show red dot ONLY if it is a fresh update
if (isFresh) {
dot?.classList.remove('hidden');
// Auto-open panel on first load if there are updates
setTimeout(() => {
openPanel();
}, 1500);
} else {
dot?.classList.add('hidden');
}
// Close handler
document.getElementById('close-notification')?.addEventListener('click', () => {
notification.classList.add('translate-y-20', 'opacity-0', 'pointer-events-none');
setupEventListeners();
function setupEventListeners() {
// State management functions
const openPanel = () => {
panel.classList.remove('hidden');
// Small delay to allow display:block to apply before transition
requestAnimationFrame(() => {
panel.classList.remove('translate-y-4', 'opacity-0', 'scale-95');
});
dot?.classList.add('hidden'); // Hide dot when opened
};
const closePanel = () => {
panel.classList.add('translate-y-4', 'opacity-0', 'scale-95');
setTimeout(() => {
panel.classList.add('hidden');
}, 300); // Match transition duration
};
// Event Listeners
// Remove old listeners to prevent duplicates if called multiple times?
// Actually, in this script structure, showNotification is called once per page load.
minimizedBtn.onclick = () => {
if (panel.classList.contains('hidden')) {
openPanel();
} else {
closePanel();
}
};
minimizeBtn.onclick = (e) => {
e.stopPropagation();
closePanel();
};
// Add event delegation for toggles
// Ensure we don't add duplicate listeners to list
// A simple way is to re-create the listener or check via a flag.
// But since this function runs once, it's fine.
if (!list.hasAttribute('data-listening')) {
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-diff-toggle]');
if (btn) {
const targetId = btn.getAttribute('data-diff-toggle');
const target = document.getElementById(targetId);
if (target) {
target.classList.toggle('hidden');
}
}
});
list.setAttribute('data-listening', 'true');
}
}
// Helper to trigger open from outside (used by auto-open logic above)
// We need to expose openPanel or move the auto-open logic inside setupEventListeners or keep it here
// The previous code called openPanel() which was defined below.
// We need to make sure openPanel is available.
function openPanel() {
panel.classList.remove('hidden');
requestAnimationFrame(() => {
panel.classList.remove('translate-y-4', 'opacity-0', 'scale-95');
});
dot?.classList.add('hidden');
}
const closePanel = () => {
panel.classList.add('translate-y-4', 'opacity-0', 'scale-95');
setTimeout(() => {
panel.classList.add('hidden');
}, 300); // Match transition duration
};
// Event Listeners
minimizedBtn.onclick = () => {
if (panel.classList.contains('hidden')) {
openPanel();
} else {
closePanel();
}
};
minimizeBtn.onclick = (e) => {
e.stopPropagation();
closePanel();
};
// Add event delegation for toggles
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-diff-toggle]');
if (btn) {
const targetId = btn.getAttribute('data-diff-toggle');
const target = document.getElementById(targetId);
if (target) {
target.classList.toggle('hidden');
}
}
});
}
// Auto-open panel on first load if it's high priority?
// Or keep it minimized to be less intrusive?
// Let's auto-open it briefly to catch attention, then user can minimize
}
// Main logic
try {
@@ -76,36 +353,124 @@
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' }
const mockDiff = [
{ value: "This is some unchanged text context.\n", added: undefined, removed: undefined },
{ value: "This line was removed.\n", added: undefined, removed: true },
{ value: "This line is new.\n", added: true, removed: undefined },
{ value: "More unchanged text.\n", added: undefined, removed: undefined }
];
showNotification(mockPosts);
return; // Skip normal logic
const mockPosts = [
{ title: 'Test New Post 1', link: '#', guid: 'test-1', isUpdated: false },
{
title: 'Test Updated Post',
link: '#',
guid: 'test-2',
isUpdated: true,
diff: mockDiff
},
];
showNotification(mockPosts, Date.now(), true);
return;
}
const currentPosts = await fetchRSS();
if (currentPosts.length === 0) return;
const storedData = localStorage.getItem(STORAGE_KEY);
const db = await openDB();
const storedPosts = await getStoredPosts(db);
const NOTIFICATION_STATE_KEY = 'fuwari-notification-state';
const INIT_TIME_KEY = 'fuwari-notification-init-time';
if (!storedData) {
// First visit: just cache current state
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPosts.map(p => p.guid)));
// Helper to get or set init time
let initTimestamp = localStorage.getItem(INIT_TIME_KEY);
if (!initTimestamp) {
initTimestamp = Date.now().toString();
localStorage.setItem(INIT_TIME_KEY, initTimestamp);
}
const initTime = parseInt(initTimestamp);
// Migration from localStorage (Old cache)
const localCache = localStorage.getItem(LOCAL_STORAGE_KEY);
if (storedPosts.length === 0 && localCache) {
console.log('[Notification] Migrating from localStorage...');
// Just init DB and clear old cache
await savePosts(db, currentPosts);
localStorage.removeItem(LOCAL_STORAGE_KEY);
// Treat as first visit (no updates)
showNotification([], Date.now(), false, initTime);
return;
}
if (storedPosts.length === 0) {
// First visit
await savePosts(db, currentPosts);
// Clear any stale notification state
localStorage.removeItem(NOTIFICATION_STATE_KEY);
showNotification([], Date.now(), false, initTime);
} else {
// Subsequent visit: compare
const cachedGuids = JSON.parse(storedData); // Array of strings
const newPosts = currentPosts.filter(post => !cachedGuids.includes(post.guid));
// Compare
const storedMap = new Map(
storedPosts
.filter(p => p.id && p.id.startsWith(`${SCOPE_ID}:`))
.map(p => [p.guid, p])
);
// Only notify if we found new posts that are genuinely newer than what we have
if (newPosts.length > 0) {
showNotification(newPosts);
const detectedChanges = [];
currentPosts.forEach(post => {
const stored = storedMap.get(post.guid);
if (!stored) {
// New post
detectedChanges.push({ ...post, isUpdated: false });
} else {
const dateUpdated = post.pubDate > stored.pubDate;
const contentChanged = post.content && stored.content && post.content !== stored.content;
if (contentChanged) {
const diff = computeDiff(stored.content, post.content);
if (diff) {
detectedChanges.push({ ...post, isUpdated: true, diff });
}
} else if (dateUpdated) {
detectedChanges.push({ ...post, isUpdated: true });
}
}
});
if (detectedChanges.length > 0) {
// CASE 1: New updates detected
const timestamp = Date.now();
// Persist notification state
const state = {
timestamp: timestamp,
items: detectedChanges
};
localStorage.setItem(NOTIFICATION_STATE_KEY, JSON.stringify(state));
// Update DB so we don't detect them again as "fresh" next time
await savePosts(db, currentPosts);
// Show with Red Dot + Auto Open
showNotification(detectedChanges, timestamp, true, initTime);
} else {
// CASE 2: No new updates
// Check if we have a persisted notification state
const persistedStateStr = localStorage.getItem(NOTIFICATION_STATE_KEY);
if (persistedStateStr) {
try {
const state = JSON.parse(persistedStateStr);
// Show persisted items, NO Red Dot, NO Auto Open
showNotification(state.items, state.timestamp, false, initTime);
} catch (e) {
console.error('Failed to parse notification state', e);
showNotification([], Date.now(), false, initTime);
}
} else {
// No updates and no history -> "No updates"
showNotification([], Date.now(), false, initTime);
}
}
// 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);

View File

@@ -14,7 +14,7 @@ import { LinkPreset } from "./types/config";
export const noticeConfig: NoticeConfig = {
enable: true,
level: "warning",
content: "请注意,该博客对于每篇文章都是有评论区的,在确保你的网络可以访问 giscus.app 的情况下,如果你看不到或者必须手动刷新后才能看见,请反馈。<br>另外如果你遇到了背景图被缩放的Bug也请反馈。",
content: "我们刚刚添加了文章更新系统,自此之后的每次文章更新都会通过右下角的小铃铛提醒您。",
};
export const siteConfig: SiteConfig = {