feat(admin): enhance admin dashboard, user management, and content editor UX

- Dashboard: add stats API with content status distribution, recent notifications,
  and recent content updates; display active users, pending reviews, unread counts
- User management: full CRUD with role assignment, search, pagination, delete dialog
- Notification center: list with unread filter, mark as read, mark all as read,
  pagination, and auto-refresh unread count
- Content editor: form validation (required fields, slug format, blur-triggered
  errors), auto-save with 3s debounce and status indicator, publish confirmation
  dialog, unsaved changes warning on leave
- Admin layout: add navigation links for user management, roles, and notifications
- admin-api: make request() method public for custom API calls
- gitignore: add reports/mutation/ to exclude mutation test output
This commit is contained in:
2026-07-31 23:05:24 +08:00
parent a995f40eae
commit c480772aec
9 changed files with 1942 additions and 120 deletions
+320
View File
@@ -0,0 +1,320 @@
'use client';
import { useEffect, useState, useCallback } 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-amber-500" />,
review_approved: <CheckCircle className="w-4 h-4 text-green-500" />,
review_rejected: <XCircle className="w-4 h-4 text-red-500" />,
item_archived: <Archive className="w-4 h-4 text-gray-500" />,
};
const NOTIFICATION_TYPE_COLORS: Record<string, string> = {
review_pending: 'bg-amber-50 border-amber-200',
review_approved: 'bg-green-50 border-green-200',
review_rejected: 'bg-red-50 border-red-200',
item_archived: 'bg-gray-50 border-gray-200',
};
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;
const fetchNotifications = useCallback(async () => {
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()}`);
setNotifications(data.items || []);
setTotal(data.total || 0);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
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-gray-900 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-gray-900">通知中心</h1>
<p className="text-sm text-gray-500 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-red-100 text-red-700">
{unreadCount} 条未读
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setUnreadOnly(!unreadOnly); setPage(1); }}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
unreadOnly
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
<Mail className="w-4 h-4" />
仅未读
</button>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
className="flex items-center gap-2 px-3 py-2 bg-white text-gray-600 rounded-lg text-sm font-medium border border-gray-200 hover:border-gray-400 transition-colors"
>
<CheckCheck className="w-4 h-4" />
全部已读
</button>
)}
<button
onClick={() => { fetchNotifications(); fetchUnreadCount(); }}
className="flex items-center gap-2 px-3 py-2 bg-white text-gray-600 rounded-lg text-sm font-medium border border-gray-200 hover:border-gray-400 transition-colors"
>
<RefreshCw className="w-4 h-4" />
刷新
</button>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* Notifications list */}
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full" />
加载中...
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<Bell className="w-12 h-12 mb-3 text-gray-200" />
<p className="text-sm">暂无通知</p>
{unreadOnly && (
<p className="text-xs text-gray-300 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-white border-gray-100'
: NOTIFICATION_TYPE_COLORS[item.type] || 'bg-blue-50 border-blue-200'
}`}
>
{/* Unread indicator */}
{!item.read && (
<div className="absolute top-4 left-4 w-2 h-2 rounded-full bg-blue-500" />
)}
{/* Type icon */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
item.read ? 'bg-gray-100' : 'bg-white'
}`}>
{NOTIFICATION_TYPE_ICONS[item.type] || <Bell className="w-4 h-4 text-gray-400" />}
</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-gray-500' : ''
}`}>
{NOTIFICATION_TYPE_LABELS[item.type] || item.type}
</span>
<span className="text-xs text-gray-400 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-gray-600' : 'text-gray-900'}`}>
{item.title}
</h3>
<p className={`text-xs mt-0.5 ${item.read ? 'text-gray-400' : 'text-gray-500'}`}>
{item.message}
</p>
{/* Actions */}
<div className="flex items-center gap-3 mt-2">
{!item.read && (
<button
onClick={() => handleMarkAsRead(item.id)}
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
<MailOpen className="w-3 h-3" />
标记已读
</button>
)}
{item.relatedItemId && item.relatedModelCode && (
<button
onClick={() => handleViewRelated(item)}
className="text-xs text-blue-600 hover:text-blue-800 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-white rounded-lg border border-gray-200">
<span className="text-sm text-gray-500">
共 {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-gray-200 hover:bg-gray-100 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-gray-200 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
)}
</div>
);
}