安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号 令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、 同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。 CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器 richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、 about/contact/erp-upgrade 补 ISR revalidate 与路由映射。 UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入 effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/ 吞错改横幅/表单 label-aria 关联。 新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data 等模块及 tests-integration 真库集成层。
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useAuth } from '@/components/admin/auth-context';
|
|
import { adminApi } from '@/lib/admin-api';
|
|
import { toast } from '@/components/ui/sonner';
|
|
import {
|
|
Bell,
|
|
CheckCheck,
|
|
Mail,
|
|
MailOpen,
|
|
Clock,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
XCircle,
|
|
Archive,
|
|
ExternalLink,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
|
|
interface NotificationItem {
|
|
id: string;
|
|
type: string;
|
|
title: string;
|
|
message: string;
|
|
read: boolean;
|
|
relatedItemId: string;
|
|
relatedModelCode: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
|
|
review_pending: '待审核',
|
|
review_approved: '审核通过',
|
|
review_rejected: '审核驳回',
|
|
item_archived: '已归档',
|
|
};
|
|
|
|
const NOTIFICATION_TYPE_ICONS: Record<string, React.ReactNode> = {
|
|
review_pending: <AlertCircle className="w-4 h-4 text-warning-text" />,
|
|
review_approved: <CheckCircle className="w-4 h-4 text-success-text" />,
|
|
review_rejected: <XCircle className="w-4 h-4 text-error-text" />,
|
|
item_archived: <Archive className="w-4 h-4 text-text-muted" />,
|
|
};
|
|
|
|
const NOTIFICATION_TYPE_COLORS: Record<string, string> = {
|
|
review_pending: 'bg-warning-bg border-border-secondary',
|
|
review_approved: 'bg-success-bg border-border-secondary',
|
|
review_rejected: 'bg-error-bg border-border-secondary',
|
|
item_archived: 'bg-bg-secondary border-border-primary',
|
|
};
|
|
|
|
export default function NotificationsPage() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [unreadOnly, setUnreadOnly] = useState(false);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const pageSize = 20;
|
|
// 单调递增的请求序号:只有最新一次请求的响应可以写进 state
|
|
const fetchSeqRef = useRef(0);
|
|
|
|
const fetchNotifications = useCallback(async () => {
|
|
const seq = ++fetchSeqRef.current;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const query = new URLSearchParams();
|
|
query.set('page', String(page));
|
|
query.set('pageSize', String(pageSize));
|
|
if (unreadOnly) query.set('unreadOnly', 'true');
|
|
|
|
const data = await adminApi.request<{
|
|
items: NotificationItem[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
totalPages: number;
|
|
}>(`/api/admin/notifications?${query.toString()}`);
|
|
if (seq !== fetchSeqRef.current) return;
|
|
setNotifications(data.items || []);
|
|
setTotal(data.total || 0);
|
|
} catch (err) {
|
|
if (seq !== fetchSeqRef.current) return;
|
|
setError(err instanceof Error ? err.message : '加载失败');
|
|
} finally {
|
|
if (seq === fetchSeqRef.current) setLoading(false);
|
|
}
|
|
}, [page, unreadOnly]);
|
|
|
|
const fetchUnreadCount = useCallback(async () => {
|
|
try {
|
|
const data = await adminApi.request<{ count: number }>('/api/admin/notifications/unread-count');
|
|
setUnreadCount(data.count || 0);
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push('/admin/login');
|
|
return;
|
|
}
|
|
if (user) {
|
|
fetchNotifications();
|
|
fetchUnreadCount();
|
|
}
|
|
}, [user, authLoading, router, fetchNotifications, fetchUnreadCount]);
|
|
|
|
const handleMarkAsRead = async (id: string) => {
|
|
try {
|
|
await adminApi.request(`/api/admin/notifications/${id}/read`, { method: 'POST' });
|
|
setNotifications((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
|
);
|
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
} catch {
|
|
toast.error('标记已读失败');
|
|
}
|
|
};
|
|
|
|
const handleMarkAllAsRead = async () => {
|
|
try {
|
|
await adminApi.request('/api/admin/notifications/read-all', { method: 'POST' });
|
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
setUnreadCount(0);
|
|
toast.success('全部标记为已读');
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const handleViewRelated = (item: NotificationItem) => {
|
|
if (item.relatedItemId && item.relatedModelCode) {
|
|
router.push(`/admin/content/${item.relatedModelCode}/${item.relatedItemId}`);
|
|
}
|
|
};
|
|
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
|
|
if (authLoading || (!user && typeof window !== 'undefined')) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin w-8 h-8 border-2 border-border-dark border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-text-primary">通知中心</h1>
|
|
<p className="text-sm text-text-muted mt-1">
|
|
共 {total} 条通知
|
|
{unreadCount > 0 && (
|
|
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-error-bg text-error-text">
|
|
{unreadCount} 条未读
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => { setUnreadOnly((prev) => !prev); setPage(1); }}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
|
unreadOnly
|
|
? 'bg-dark-bg text-white border-border-dark'
|
|
: 'bg-bg-primary text-text-tertiary border-border-primary hover:border-border-secondary'
|
|
}`}
|
|
>
|
|
<Mail className="w-4 h-4" />
|
|
仅未读
|
|
</button>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={handleMarkAllAsRead}
|
|
className="flex items-center gap-2 px-3 py-2 bg-bg-primary text-text-tertiary rounded-lg text-sm font-medium border border-border-primary hover:border-border-secondary transition-colors"
|
|
>
|
|
<CheckCheck className="w-4 h-4" />
|
|
全部已读
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => { fetchNotifications(); fetchUnreadCount(); }}
|
|
className="flex items-center gap-2 px-3 py-2 bg-bg-primary text-text-tertiary rounded-lg text-sm font-medium border border-border-primary hover:border-border-secondary transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
刷新
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-4 bg-error-bg border border-border-secondary rounded-lg text-sm text-error-text">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Notifications list */}
|
|
<div className="space-y-2">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12 text-text-muted">
|
|
<div className="flex items-center gap-2">
|
|
<div className="animate-spin w-4 h-4 border-2 border-border-secondary border-t-transparent rounded-full" />
|
|
加载中...
|
|
</div>
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-text-muted">
|
|
<Bell className="w-12 h-12 mb-3 text-text-muted" />
|
|
<p className="text-sm">暂无通知</p>
|
|
{unreadOnly && (
|
|
<p className="text-xs text-text-muted mt-1">没有未读通知,试试查看全部</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
notifications.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className={`relative flex items-start gap-4 p-4 rounded-lg border transition-colors ${
|
|
item.read
|
|
? 'bg-bg-primary border-border-light'
|
|
: NOTIFICATION_TYPE_COLORS[item.type] || 'bg-info-bg border-border-secondary'
|
|
}`}
|
|
>
|
|
{/* Unread indicator */}
|
|
{!item.read && (
|
|
<div className="absolute top-4 left-4 w-2 h-2 rounded-full bg-info" />
|
|
)}
|
|
|
|
{/* Type icon */}
|
|
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
|
item.read ? 'bg-bg-hover' : 'bg-bg-primary'
|
|
}`}>
|
|
{NOTIFICATION_TYPE_ICONS[item.type] || <Bell className="w-4 h-4 text-text-muted" />}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
|
item.read ? 'text-text-muted' : ''
|
|
}`}>
|
|
{NOTIFICATION_TYPE_LABELS[item.type] || item.type}
|
|
</span>
|
|
<span className="text-xs text-text-muted flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{new Date(item.createdAt).toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</span>
|
|
</div>
|
|
<h3 className={`text-sm font-medium ${item.read ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
|
{item.title}
|
|
</h3>
|
|
<p className={`text-xs mt-0.5 ${item.read ? 'text-text-muted' : 'text-text-muted'}`}>
|
|
{item.message}
|
|
</p>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-3 mt-2">
|
|
{!item.read && (
|
|
<button
|
|
onClick={() => handleMarkAsRead(item.id)}
|
|
className="text-xs text-text-muted hover:text-text-secondary flex items-center gap-1"
|
|
>
|
|
<MailOpen className="w-3 h-3" />
|
|
标记已读
|
|
</button>
|
|
)}
|
|
{item.relatedItemId && item.relatedModelCode && (
|
|
<button
|
|
onClick={() => handleViewRelated(item)}
|
|
className="text-xs text-info-text hover:text-info flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
查看详情
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between px-4 py-3 bg-bg-primary rounded-lg border border-border-primary">
|
|
<span className="text-sm text-text-muted">
|
|
共 {total} 条,第 {page}/{totalPages} 页
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="px-3 py-1.5 text-sm rounded border border-border-primary hover:bg-bg-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
上一页
|
|
</button>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="px-3 py-1.5 text-sm rounded border border-border-primary hover:bg-bg-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
下一页
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |