Files
novalon-website/src/app/admin/page.tsx
T
zhangxiang c480772aec 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
2026-07-31 23:05:24 +08:00

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