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:
+240
-29
@@ -4,7 +4,48 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { FileText, Briefcase, Box, Layers, Image, BarChart3, Newspaper, Settings } from 'lucide-react';
|
||||
import {
|
||||
FileText,
|
||||
Briefcase,
|
||||
Box,
|
||||
Layers,
|
||||
Image,
|
||||
BarChart3,
|
||||
Newspaper,
|
||||
Settings,
|
||||
Users,
|
||||
Bell,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Archive,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
models: number;
|
||||
items: number;
|
||||
zones: number;
|
||||
activeUsers: number;
|
||||
pendingReviews: number;
|
||||
unreadNotifications: number;
|
||||
modelItemCounts: Array<{ modelCode: string; count: number }>;
|
||||
statusCounts: Array<{ status: string; count: number }>;
|
||||
recentNotifications: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}>;
|
||||
recentItems: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
modelCode: string;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const modelCards = [
|
||||
{ code: 'news', name: '新闻资讯', icon: Newspaper, color: 'bg-blue-50 text-blue-600' },
|
||||
@@ -16,23 +57,62 @@ const modelCards = [
|
||||
{ code: 'stat-item', name: '数据指标', icon: BarChart3, color: 'bg-indigo-50 text-indigo-600' },
|
||||
];
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
news: '新闻资讯',
|
||||
'case-study': '案例研究',
|
||||
service: '服务管理',
|
||||
product: '产品管理',
|
||||
solution: '解决方案',
|
||||
'hero-banner': 'Hero Banner',
|
||||
'stat-item': '数据指标',
|
||||
'about-page': '关于我们',
|
||||
'team-page': '团队介绍',
|
||||
'contact-page': '联系我们',
|
||||
'legal-page': '法律页面',
|
||||
'standalone-product': '独立产品',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
review: '待审核',
|
||||
published: '已发布',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
review: 'bg-amber-100 text-amber-700',
|
||||
published: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
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" />,
|
||||
};
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState({ models: 0, items: 0, zones: 0 });
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
adminApi.getStats().then((s) => setStats(s)).catch(console.error).finally(() => setFetching(false));
|
||||
adminApi.request<DashboardStats>('/api/admin/stats')
|
||||
.then((s) => setStats(s))
|
||||
.catch(console.error)
|
||||
.finally(() => setFetching(false));
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
if (loading || (!user && typeof window !== 'undefined')) {
|
||||
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" />
|
||||
@@ -47,45 +127,176 @@ export default function AdminDashboardPage() {
|
||||
<p className="text-sm text-gray-500 mt-1">欢迎回来,{user?.nickname || user?.username}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{[
|
||||
{ label: '内容模型', value: stats.models, icon: FileText },
|
||||
{ label: '内容条目', value: stats.items, icon: Layers },
|
||||
{ label: '页面区域', value: stats.zones, icon: Briefcase },
|
||||
{ label: '内容模型', value: stats?.models ?? '-', icon: FileText, color: 'bg-blue-50' },
|
||||
{ label: '内容条目', value: stats?.items ?? '-', icon: Layers, color: 'bg-purple-50' },
|
||||
{ label: '页面区域', value: stats?.zones ?? '-', icon: Briefcase, color: 'bg-amber-50' },
|
||||
{ label: '活跃用户', value: stats?.activeUsers ?? '-', icon: Users, color: 'bg-green-50' },
|
||||
{
|
||||
label: '待审核',
|
||||
value: stats?.pendingReviews ?? '-',
|
||||
icon: AlertCircle,
|
||||
color: 'bg-red-50',
|
||||
highlight: (stats?.pendingReviews ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
label: '未读通知',
|
||||
value: stats?.unreadNotifications ?? '-',
|
||||
icon: Bell,
|
||||
color: 'bg-indigo-50',
|
||||
highlight: (stats?.unreadNotifications ?? 0) > 0,
|
||||
},
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div
|
||||
key={stat.label}
|
||||
className={`bg-white rounded-lg border p-4 ${
|
||||
stat.highlight ? 'border-red-200 ring-1 ring-red-100' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
<p className="text-xs text-gray-500">{stat.label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${
|
||||
stat.highlight ? 'text-red-600' : 'text-gray-900'
|
||||
}`}>
|
||||
{fetching ? '-' : stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<stat.icon className="w-5 h-5 text-gray-600" />
|
||||
<div className={`w-10 h-10 rounded-lg ${stat.color} flex items-center justify-center`}>
|
||||
<stat.icon className={`w-5 h-5 ${
|
||||
stat.highlight ? 'text-red-500' : 'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Content status distribution */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">内容状态分布</h2>
|
||||
{fetching ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400 text-sm">加载中...</div>
|
||||
) : !stats?.statusCounts?.length ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.statusCounts.map((s) => (
|
||||
<div key={s.status} className="flex items-center justify-between">
|
||||
<span className={`text-sm px-2 py-0.5 rounded ${STATUS_COLORS[s.status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{STATUS_LABELS[s.status] || s.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{s.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent notifications */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-900">最近通知</h2>
|
||||
<button
|
||||
onClick={() => router.push('/admin/notifications')}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
{fetching ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400 text-sm">加载中...</div>
|
||||
) : !stats?.recentNotifications?.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
|
||||
<Bell className="w-8 h-8 mb-2 text-gray-200" />
|
||||
<span className="text-sm">暂无通知</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.recentNotifications.map((n) => (
|
||||
<div key={n.id} className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{NOTIFICATION_TYPE_ICONS[n.type] || <Bell className="w-4 h-4 text-gray-400" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm truncate ${n.read ? 'text-gray-500' : 'text-gray-900 font-medium'}`}>
|
||||
{n.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(n.createdAt).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
{!n.read && <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0 mt-1.5" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent content updates */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">最近更新</h2>
|
||||
{fetching ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400 text-sm">加载中...</div>
|
||||
) : !stats?.recentItems?.length ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无更新</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.recentItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => router.push(`/admin/content/${item.modelCode}/${item.id}`)}
|
||||
className="w-full text-left flex items-start gap-3 hover:bg-gray-50 rounded-lg p-2 -mx-2 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900 truncate">{item.title || '无标题'}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-gray-400">
|
||||
{MODEL_LABELS[item.modelCode] || item.modelCode}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
STATUS_COLORS[item.status] || 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">内容管理</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{modelCards.map((card) => (
|
||||
<button
|
||||
key={card.code}
|
||||
onClick={() => router.push(`/admin/content/${card.code}`)}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-colors text-center"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${card.color} flex items-center justify-center`}>
|
||||
<card.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">{card.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{modelCards.map((card) => {
|
||||
const count = stats?.modelItemCounts?.find((m) => m.modelCode === card.code)?.count;
|
||||
return (
|
||||
<button
|
||||
key={card.code}
|
||||
onClick={() => router.push(`/admin/content/${card.code}`)}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-colors text-center relative"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${card.color} flex items-center justify-center`}>
|
||||
<card.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">{card.name}</span>
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-gray-400">{count} 条</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user