import { useEffect, useState, useMemo } from "react";
// 文章更新条目类型
interface ArticleChange {
title: string;
link: string;
isNew: boolean;
isModified: boolean;
newText: string;
}
// 本地缓存指纹类型
interface ArticleCacheItem {
link: string;
title: string;
fingerprint: string;
}
// 纯文本清洗
const cleanText = (text: string) => {
return text.replace(/\s+/g, " ").trim();
};
// 生成内容指纹
const getFingerprint = (text: string) => {
return btoa(encodeURIComponent(cleanText(text)));
};
// 解码指纹,还原旧版正文
const decodeFingerprint = (fp: string): string => {
try {
return decodeURIComponent(atob(fp));
} catch {
return "";
}
};
// 句子级差异比对
const getDiffSentences = (oldText: string, newText: string): string[] => {
const split = (s: string) =>
s.split(/[。!?;]/).map(t => t.trim()).filter(Boolean);
const oldArr = split(oldText);
const newArr = split(newText);
return newArr.filter(t => !oldArr.includes(t));
};
export default function RootNoticeLayout({ children }: { children: React.ReactNode }) {
const [changes, setChanges] = useState<ArticleChange[]>([]);
const [showPopup, setShowPopup] = useState(true);
const [firstVisit, setFirstVisit] = useState(true);
const CACHE_KEY = "blog_articles_v5";
// 读取本地文章指纹缓存
const localCache = useMemo<ArticleCacheItem[]>(() => {
try {
return JSON.parse(localStorage.getItem(CACHE_KEY) || "[]");
} catch {
return [];
}
}, []);
// 安全清空:仅关闭提醒+移除高亮,不删除核心缓存
const clearAllUpdates = () => {
setChanges([]);
setShowPopup(false);
document.querySelectorAll(".diff-marked").forEach(el => {
el.style.background = "";
el.style.textDecoration = "";
el.style.textDecorationColor = "";
el.style.textDecorationThickness = "";
el.style.textUnderlineOffset = "";
});
document.querySelectorAll(".article-update-tip").forEach(el => el.remove());
};
// 请求 RSS 检测文章更新
const fetchAndDetectChanges = async () => {
try {
const res = await fetch("/rss.xml", { signal: AbortSignal.timeout(10000) });
if (!res.ok) return;
const text = await res.text();
const xml = new DOMParser().parseFromString(text, "application/xml");
const items = xml.querySelectorAll("item");
const newCache: ArticleCacheItem[] = [];
const newChanges: ArticleChange[] = [];
items.forEach(item => {
const title = item.querySelector("title")?.textContent || "无标题";
const link = item.querySelector("link")?.textContent || "#";
// 读取 content:encoded 完整正文,适配 CDATA
const content = item.querySelector("*|encoded")?.textContent || "";
const fp = getFingerprint(content);
newCache.push({ link, title, fingerprint: fp });
const found = localCache.find(x => x.link === link);
if (!found) {
newChanges.push({ title, link, isNew: true, isModified: false, newText: content });
} else if (found.fingerprint !== fp) {
newChanges.push({ title, link, isNew: false, isModified: true, newText: content });
}
});
localStorage.setItem(CACHE_KEY, JSON.stringify(newCache));
setChanges(newChanges);
} catch (err) {}
};
useEffect(() => {
// 首次访问直接初始化缓存
if (localStorage.getItem(CACHE_KEY)) {
setFirstVisit(false);
}
fetchAndDetectChanges();
}, []);
// 文章页:局部修改内容下划线高亮
useEffect(() => {
const path = window.location.pathname;
const curr = changes.find(x => x.link === path);
if (!curr || curr.isNew) return;
const article = document.querySelector(".prose") || document.querySelector(".article-content");
if (!article || article.querySelector(".diff-marked")) return;
const cache = localCache.find(x => x.link === path);
if (!cache) return;
const oldText = decodeFingerprint(cache.fingerprint);
const newText = cleanText(curr.newText);
const diffs = getDiffSentences(oldText, newText);
if (diffs.length === 0) return;
// 遍历文本节点,标记改动内容
const walk = (el: Element) => {
el.childNodes.forEach(node => {
if (node.nodeType === 3) {
let txt = node.textContent || "";
let changed = false;
diffs.forEach(seg => {
if (txt.includes(seg)) {
changed = true;
txt = txt.replaceAll(seg,
`<span class="diff-marked" style="background:rgba(34,197,94,0.12);text-decoration:underline;text-decoration-color:#22c55e;text-underline-offset:2px;">${seg}</span>`
);
}
});
if (changed && node.parentNode) {
const span = document.createElement("span");
span.innerHTML = txt;
node.parentNode.replaceChild(span, node);
}
} else if (node.nodeType === 1) {
walk(node as Element);
}
});
};
walk(article);
// 顶部更新提示条
const tip = document.createElement("div");
tip.style.cssText = "margin:0 0 16px;padding:8px 14px;border-left:3px solid #22c55e;background:rgba(34,197,94,0.08);border-radius:6px;font-size:14px;";
tip.textContent = "✏️ 本文有内容更新,修改段落已标注";
article.parentNode?.insertBefore(tip, article);
}, [changes, localCache]);
return (
<>
{children}
{/* 更新弹窗 + 滚动限制,防止大量更新撑爆屏幕 */}
{!firstVisit && showPopup && changes.length > 0 && (
<div style={{
position: "fixed",
bottom: "80px",
right: "20px",
width: "320px",
background: "var(--background)",
border: "1px solid var(--border)",
borderRadius: "14px",
padding: "14px 16px",
boxShadow: "0 10px 30px -8px rgba(0,0,0,0.08)",
zIndex: 999
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "10px" }}>
<div style={{ fontWeight: 600 }}>最新更新</div>
<div style={{ display: "flex", gap: "8px" }}>
<button onClick={clearAllUpdates} style={{ background: "none", border: "none", cursor: "pointer" }}>🗑️</button>
<button onClick={() => setShowPopup(false)} style={{ background: "none", border: "none", cursor: "pointer" }}>×</button>
</div>
</div>
<div style={{ maxHeight: "220px", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px" }}>
{changes.map((item, i) => (
<a
key={i}
href={item.link}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
color: "var(--foreground)",
textDecoration: "none",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
}}>
<span style={{
fontSize: "11px",
padding: "2px 6px",
border: "1px solid #f97316",
color: "#f97316",
borderRadius: "4px"
}}>
{item.isNew ? "新增" : "更新"}
</span>
{item.title}
</a>
))}
</div>
</div>
)}
</>
);
}