feat(v2.7): 接入协作治理前端体验
This commit is contained in:
78
apps/web/components/notification/NotificationBell.tsx
Normal file
78
apps/web/components/notification/NotificationBell.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, CheckCheck } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
assignment: '分配',
|
||||
mention: '提及',
|
||||
risk_alert: '风险',
|
||||
overdue_item: '逾期',
|
||||
};
|
||||
|
||||
export function NotificationBell() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) void fetchNotifications(user.id).catch(() => {});
|
||||
}, [fetchNotifications, user?.id]);
|
||||
|
||||
if (!user?.id) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="h-4 w-4" strokeWidth={1.8} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[9px] font-semibold leading-none text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-9 right-0 z-50 w-80 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-3">
|
||||
<span className="text-[12px] font-semibold text-[var(--ink)]">通知</span>
|
||||
<button
|
||||
onClick={() => markAllRead(user.id).catch(() => {})}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" /> 全部已读
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无通知</div>
|
||||
) : (
|
||||
notifications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => !item.readAt && markRead(item.id, user.id).catch(() => {})}
|
||||
className={`block w-full border-b border-[var(--line)] px-3 py-2.5 text-left last:border-b-0 hover:bg-[var(--bg-subtle)] ${item.readAt ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${item.readAt ? 'bg-zinc-300' : 'bg-blue-600'}`} />
|
||||
<span className="rounded bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[10px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||
<span className="ml-auto text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--ink)]">{item.title}</div>
|
||||
{item.body && <div className="mt-0.5 line-clamp-2 text-[11px] leading-4 text-[var(--ink-muted)]">{item.body}</div>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user