给终端更新公告加未读提醒
This commit is contained in:
@@ -20,6 +20,8 @@ type UpdateAnnouncementButtonProps = {
|
|||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1";
|
||||||
|
|
||||||
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
|
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
|
||||||
{
|
{
|
||||||
id: "live-observation-chart-2026-06",
|
id: "live-observation-chart-2026-06",
|
||||||
@@ -71,10 +73,40 @@ function formatUpdatedAt(value: string | undefined, isEn: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadSeenAnnouncementIds() {
|
||||||
|
if (typeof window === "undefined") return new Set<string>();
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(UPDATE_ANNOUNCEMENT_SEEN_KEY);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
|
||||||
|
} catch {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSeenAnnouncementIds(ids: Set<string>) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
UPDATE_ANNOUNCEMENT_SEEN_KEY,
|
||||||
|
JSON.stringify(Array.from(ids).slice(-50)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures; the unread dot may reappear but the announcement remains usable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
|
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
|
||||||
|
const [seenLoaded, setSeenLoaded] = useState(false);
|
||||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSeenIds(loadSeenAnnouncementIds());
|
||||||
|
setSeenLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
@@ -98,6 +130,17 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps
|
|||||||
() => formatUpdatedAt(announcement?.publishedAt, isEn),
|
() => formatUpdatedAt(announcement?.publishedAt, isEn),
|
||||||
[announcement?.publishedAt, isEn],
|
[announcement?.publishedAt, isEn],
|
||||||
);
|
);
|
||||||
|
const hasUnread = Boolean(seenLoaded && announcement?.id && !seenIds.has(announcement.id));
|
||||||
|
|
||||||
|
const markAnnouncementAsSeen = (announcementId: string) => {
|
||||||
|
setSeenIds((current) => {
|
||||||
|
if (current.has(announcementId)) return current;
|
||||||
|
const next = new Set(current);
|
||||||
|
next.add(announcementId);
|
||||||
|
saveSeenAnnouncementIds(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!announcement || (!text.title && !text.body)) return null;
|
if (!announcement || (!text.title && !text.body)) return null;
|
||||||
|
|
||||||
@@ -105,13 +148,34 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps
|
|||||||
<div ref={shellRef} className="relative">
|
<div ref={shellRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => {
|
||||||
className="inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
|
setOpen((value) => {
|
||||||
|
const next = !value;
|
||||||
|
if (next) markAnnouncementAsSeen(announcement.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="relative inline-flex h-7 items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-2 text-[10px] font-bold uppercase tracking-wide text-blue-700 transition-colors hover:border-blue-300 hover:bg-blue-100"
|
||||||
title={isEn ? "Update announcement" : "更新公告"}
|
title={isEn ? "Update announcement" : "更新公告"}
|
||||||
|
aria-label={
|
||||||
|
hasUnread
|
||||||
|
? isEn
|
||||||
|
? "Update announcement, unread"
|
||||||
|
: "更新公告,有未读内容"
|
||||||
|
: isEn
|
||||||
|
? "Update announcement"
|
||||||
|
: "更新公告"
|
||||||
|
}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
<Megaphone size={12} />
|
<Megaphone size={12} />
|
||||||
{isEn ? "Updates" : "更新公告"}
|
{isEn ? "Updates" : "更新公告"}
|
||||||
|
{hasUnread && (
|
||||||
|
<span
|
||||||
|
className="scan-update-announcement-unread absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg">
|
<div className="absolute left-0 top-8 z-50 w-[min(360px,calc(100vw-32px))] rounded-md border border-slate-200 bg-white p-3 text-left shadow-lg">
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export function runTests() {
|
|||||||
!componentSource.includes("setInterval("),
|
!componentSource.includes("setInterval("),
|
||||||
"announcement component must use hardcoded zh/en release notes with an expiry time and no backend polling",
|
"announcement component must use hardcoded zh/en release notes with an expiry time and no backend polling",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
componentSource.includes("polyweather_update_announcement_seen_v1") &&
|
||||||
|
componentSource.includes("loadSeenAnnouncementIds") &&
|
||||||
|
componentSource.includes("saveSeenAnnouncementIds") &&
|
||||||
|
componentSource.includes("markAnnouncementAsSeen") &&
|
||||||
|
componentSource.includes("announcement.id") &&
|
||||||
|
componentSource.includes("scan-update-announcement-unread"),
|
||||||
|
"announcement component must persist seen announcement ids and render an unread indicator for unseen updates",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
componentSource.includes("实时观测链路已和模型缓存拆分") &&
|
componentSource.includes("实时观测链路已和模型缓存拆分") &&
|
||||||
componentSource.includes("SSE") &&
|
componentSource.includes("SSE") &&
|
||||||
|
|||||||
Reference in New Issue
Block a user