- 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
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
'use client';
|
|
|
|
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,
|
|
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' },
|
|
{ code: 'case-study', name: '案例研究', icon: Briefcase, color: 'bg-amber-50 text-amber-600' },
|
|
{ code: 'service', name: '服务管理', icon: Settings, color: 'bg-green-50 text-green-600' },
|
|
{ code: 'product', name: '产品管理', icon: Box, color: 'bg-purple-50 text-purple-600' },
|
|
{ code: 'solution', name: '解决方案', icon: Layers, color: 'bg-teal-50 text-teal-600' },
|
|
{ code: 'hero-banner', name: 'Hero Banner', icon: Image, color: 'bg-rose-50 text-rose-600' },
|
|
{ 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: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [fetching, setFetching] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push('/admin/login');
|
|
return;
|
|
}
|
|
if (user) {
|
|
adminApi.request<DashboardStats>('/api/admin/stats')
|
|
.then((s) => setStats(s))
|
|
.catch(console.error)
|
|
.finally(() => setFetching(false));
|
|
}
|
|
}, [user, authLoading, router]);
|
|
|
|
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>
|
|
<h1 className="text-xl font-bold text-gray-900">仪表盘</h1>
|
|
<p className="text-sm text-gray-500 mt-1">欢迎回来,{user?.nickname || user?.username}</p>
|
|
</div>
|
|
|
|
{/* Stats cards */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
{[
|
|
{ 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 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-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 ${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) => {
|
|
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>
|
|
);
|
|
} |