Files
novalon-website/src/app/admin/notifications/page.tsx
T
zhangxiang a366bd1400 fix(qa): 第五轮系统性质量保障缺陷修复 + 单元/集成测试
安全/鉴权:登出清服务端 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 真库集成层。
2026-09-28 10:48:07 +08:00

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>
);
}