142 lines
3.6 KiB
TypeScript
142 lines
3.6 KiB
TypeScript
export type NotificationType = "success" | "error" | "info" | "warning";
|
||
|
||
export interface AppNotification {
|
||
id: string;
|
||
type: NotificationType;
|
||
title: string;
|
||
message?: string;
|
||
href?: string;
|
||
read: boolean;
|
||
createdAt: string;
|
||
}
|
||
|
||
const STORAGE_KEY_PREFIX = "haixun-notifications";
|
||
const MAX_ITEMS = 80;
|
||
|
||
type Listener = () => void;
|
||
const listeners = new Set<Listener>();
|
||
|
||
let scopedUserId: string | null = null;
|
||
let cachedNotifications: AppNotification[] = [];
|
||
let cachedUnreadCount = 0;
|
||
let initialized = false;
|
||
|
||
function storageKey() {
|
||
return scopedUserId ? `${STORAGE_KEY_PREFIX}-${scopedUserId}` : `${STORAGE_KEY_PREFIX}-anonymous`;
|
||
}
|
||
|
||
function emit() {
|
||
listeners.forEach((fn) => fn());
|
||
}
|
||
|
||
/** 切換登入使用者時重載通知,避免看到別人的 local 紀錄。 */
|
||
export function setNotificationScope(userId: string) {
|
||
if (scopedUserId === userId) return;
|
||
scopedUserId = userId;
|
||
initialized = false;
|
||
cachedNotifications = [];
|
||
cachedUnreadCount = 0;
|
||
ensureInitialized();
|
||
}
|
||
|
||
function load(): AppNotification[] {
|
||
if (typeof window === "undefined") return [];
|
||
try {
|
||
const raw = localStorage.getItem(storageKey());
|
||
if (!raw) return [];
|
||
return JSON.parse(raw) as AppNotification[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function refreshUnreadCount() {
|
||
cachedUnreadCount = cachedNotifications.filter((n) => !n.read).length;
|
||
}
|
||
|
||
function ensureInitialized() {
|
||
if (initialized || typeof window === "undefined") return;
|
||
cachedNotifications = load();
|
||
refreshUnreadCount();
|
||
initialized = true;
|
||
}
|
||
|
||
function createNotificationId(): string {
|
||
const c = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||
if (c && typeof c.randomUUID === "function") {
|
||
return c.randomUUID();
|
||
}
|
||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
||
}
|
||
|
||
function save(items: AppNotification[]) {
|
||
cachedNotifications = items.slice(0, MAX_ITEMS);
|
||
refreshUnreadCount();
|
||
initialized = true;
|
||
if (typeof window !== "undefined") {
|
||
try {
|
||
localStorage.setItem(storageKey(), JSON.stringify(cachedNotifications));
|
||
} catch {
|
||
// localStorage 已滿或不可寫入(例如 Safari 隱私模式),略過持久化
|
||
}
|
||
}
|
||
emit();
|
||
}
|
||
|
||
export function subscribe(listener: Listener): () => void {
|
||
listeners.add(listener);
|
||
return () => listeners.delete(listener);
|
||
}
|
||
|
||
export function getNotifications(): AppNotification[] {
|
||
ensureInitialized();
|
||
return cachedNotifications;
|
||
}
|
||
|
||
export function getUnreadCount(): number {
|
||
ensureInitialized();
|
||
return cachedUnreadCount;
|
||
}
|
||
|
||
/**
|
||
* 寫入通知中心 — 僅用於 AI 產出、背景任務完成/失敗等可稍後查看的訊息。
|
||
* 儲存、刪除、表單驗證等直接操作請用頁面內 InlineAlert(useActionFeedback)。
|
||
*/
|
||
export function notify(params: {
|
||
type: NotificationType;
|
||
title: string;
|
||
message?: string;
|
||
href?: string;
|
||
}) {
|
||
ensureInitialized();
|
||
const item: AppNotification = {
|
||
id: createNotificationId(),
|
||
type: params.type,
|
||
title: params.title,
|
||
message: params.message,
|
||
href: params.href,
|
||
read: false,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
save([item, ...cachedNotifications]);
|
||
return item.id;
|
||
}
|
||
|
||
export function markRead(id: string) {
|
||
ensureInitialized();
|
||
save(cachedNotifications.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||
}
|
||
|
||
export function markAllRead() {
|
||
ensureInitialized();
|
||
save(cachedNotifications.map((n) => ({ ...n, read: true })));
|
||
}
|
||
|
||
export function clearAll() {
|
||
save([]);
|
||
}
|
||
|
||
export function removeNotification(id: string) {
|
||
ensureInitialized();
|
||
save(cachedNotifications.filter((n) => n.id !== id));
|
||
} |