Files
fuwari/src/components/widget/NewPostNotification.astro
二叉树树 ea9e721d22 fix(NewPostNotification): 修复RSS文章检测中的重复通知问题
修复因GUID处理不一致导致的重复通知。主要修改包括:
- 将数据库版本升级至3,确保对象存储使用正确的键路径
- 新增GUID规范化函数,统一处理不同格式的标识符
- 优化文章比较逻辑,使用规范化后的GUID进行匹配
- 改进SCOPE_ID生成方式,基于BASE_URL而非路径名
2026-01-30 03:38:05 +08:00

574 lines
26 KiB
Plaintext

<style>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--primary);
border-radius: 20px;
opacity: 0.5;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--primary);
opacity: 0.8;
}
/* Firefox */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--primary) transparent;
}
</style>
<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 translate-y-20 opacity-0 transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] 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 animate-pulse"></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-[90vw] w-80 transform translate-y-4 opacity-0 scale-95 origin-bottom-right transition-all duration-300 hidden absolute bottom-16 right-0 sm:bottom-14">
<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>
<div class="flex items-center gap-1">
<button id="clear-notification" class="text-black/50 dark:text-white/50 hover:text-red-500 transition-colors p-1 rounded-md hover:bg-red-500/10" title="清空通知">
<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"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>
</button>
<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" title="隐藏">
<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>
<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 overflow-x-hidden custom-scrollbar"></div>
</div>
</div>
<script>
import * as Diff from 'diff';
(async function() {
const DB_NAME = 'fuwari-rss-store';
const DB_VERSION = 3;
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 BASE_PATH = (import.meta.env.BASE_URL ?? '/');
const SCOPE_ID = BASE_PATH.replace(/^\/+|\/+$/g, '') || '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)) {
const existingStore = event.target.transaction.objectStore(STORE_NAME);
if (existingStore.keyPath !== 'id') {
db.deleteObjectStore(STORE_NAME);
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
return;
}
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
};
});
}
function normalizeGuid(guid, link) {
const value = (guid || link || '').trim();
if (!value) return '';
try {
const url = new URL(value, window.location.origin);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return value;
}
}
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 {
const response = await fetch('/rss.xml', { cache: 'no-store' });
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 => {
const title = item.querySelector('title')?.textContent || '';
const link = (item.querySelector('link')?.textContent || '').trim();
const rawGuid = (item.querySelector('guid')?.textContent || '').trim();
const guid = normalizeGuid(rawGuid || link, 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 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');
const clearBtn = document.getElementById('clear-notification');
const NOTIFICATION_STATE_KEY = 'fuwari-notification-state';
const INIT_TIME_KEY = 'fuwari-notification-init-time';
if (!minimizedBtn || !panel || !list) return;
// Show the minimized bell with animation
// Add a small delay to make the entrance noticeable after page load
requestAnimationFrame(() => {
minimizedBtn.classList.remove('translate-y-20', 'opacity-0');
});
const initTimeStr = new Date(initTime).toLocaleString();
const checkTimeStr = new Date(timestamp).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 font-medium mb-2">暂无文章更新</p>
<div class="text-xs opacity-70 bg-gray-100 dark:bg-gray-800 rounded px-2 py-1 inline-block">
${initTimeStr} - ${checkTimeStr}
</div>
</div>`;
dot?.classList.add('hidden');
// Setup event listeners even if no posts (so it can be opened)
setupEventListeners();
return;
}
// Show timestamp header
let html = `
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2 px-1 flex flex-col gap-0.5">
<div class="font-medium">发现更新</div>
<div class="opacity-70 text-[10px]">${initTimeStr} - ${checkTimeStr}</div>
</div>`;
newPosts.forEach(post => {
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">更新</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">新文章</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 onclick="window.toggleDiff('${safeId}')" class="ml-auto text-xs text-[var(--primary)] hover:underline focus:outline-none pointer-events-auto">
查看变更
</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 break-all whitespace-pre-wrap' :
part.removed ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 block my-1 p-1 rounded break-all whitespace-pre-wrap' :
'text-gray-500 dark:text-gray-400 block my-1 p-1 break-all whitespace-pre-wrap';
return `<div class="${colorClass}">${part.value}</div>`;
}).join('')}
</div>
` : ''}
</div>`;
});
list.innerHTML = html;
// 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');
}
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();
// Completely hide the bell after closing the panel via the 'X' button
// Wait for panel closing animation to finish
setTimeout(() => {
minimizedBtn.classList.add('translate-y-20', 'opacity-0');
minimizedBtn.classList.add('pointer-events-none'); // Prevent clicks while hidden
}, 300);
};
if (clearBtn) {
clearBtn.onclick = (e) => {
e.stopPropagation();
// Clear state from localStorage
localStorage.removeItem(NOTIFICATION_STATE_KEY);
// Update init time to current time since we are "resetting" the baseline
const now = Date.now();
localStorage.setItem(INIT_TIME_KEY, now.toString());
// Refresh view with new init time
showNotification([], now, false, now);
};
}
// 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
// Remove and re-add to ensure freshness
// Or better, just use one listener on the container that always works
// The previous check if (!list.hasAttribute('data-listening')) might be persisting across re-renders improperly if Astro keeps DOM
// Let's make it robust:
// Remove existing listener if possible? No easy way without reference.
// Instead, let's use a unique attribute or just replace the node (expensive)
// Or simpler: assign onclick handler to the list container which bubbles up
// Use a window-level handler for maximum robustness, filtering by ID
window.toggleDiff = function(safeId) {
const target = document.getElementById(safeId);
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 {
// 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 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 }
];
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, Date.now());
return;
}
const currentPosts = await fetchRSS();
if (currentPosts.length === 0) return;
const db = await openDB();
const storedPosts = await getStoredPosts(db);
const NOTIFICATION_STATE_KEY = 'fuwari-notification-state';
const INIT_TIME_KEY = 'fuwari-notification-init-time';
// 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 {
// Compare
const storedMap = new Map(
storedPosts.map(p => [p.id || generateId(p.guid || p.link || p.title), p])
);
const detectedChanges = [];
currentPosts.forEach(post => {
const stored = storedMap.get(generateId(post.guid));
if (!stored) {
// New post
detectedChanges.push({ ...post, isUpdated: false });
} else {
// Check for ANY change (Title, Date, Description, Content)
const titleChanged = post.title !== stored.title;
const dateChanged = post.pubDate !== stored.pubDate;
const descriptionChanged = post.description !== stored.description;
const contentChanged = post.content !== stored.content;
if (titleChanged || dateChanged || descriptionChanged || contentChanged) {
// Calculate diff for content
let diff = null;
if (contentChanged) {
diff = computeDiff(stored.content, post.content);
} else if (descriptionChanged) {
// If content is same but description changed, show description diff
diff = computeDiff(stored.description, post.description);
// Add a label to indicate this is description diff?
// The UI doesn't support labels inside diff yet, but at least user sees the text change.
} else if (titleChanged) {
diff = computeDiff(stored.title, post.title);
}
detectedChanges.push({
...post,
isUpdated: true,
diff
});
}
}
});
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);
}
}
}
} catch (e) {
console.error('New post check failed:', e);
}
})();
</script>