From c480772aeca3bf7525ec7fcb50e5e9a08eb2a957 Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Fri, 31 Jul 2026 23:05:24 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + .../content/[modelCode]/[itemId]/page.tsx | 576 +++++++++++++++--- src/app/admin/notifications/page.tsx | 320 ++++++++++ src/app/admin/page.tsx | 269 +++++++- src/app/admin/users/page.tsx | 540 ++++++++++++++++ src/app/api/admin/stats/route.ts | 82 +++ src/app/api/admin/users/route.ts | 263 ++++++++ src/components/admin/admin-layout.tsx | 9 + src/lib/admin-api.ts | 2 +- 9 files changed, 1942 insertions(+), 120 deletions(-) create mode 100644 src/app/admin/notifications/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/admin/stats/route.ts create mode 100644 src/app/api/admin/users/route.ts diff --git a/.gitignore b/.gitignore index e7c80a7..31f3936 100644 --- a/.gitignore +++ b/.gitignore @@ -256,6 +256,7 @@ heading-hierarchy-report.json reports/e2e/ reports/performance/ reports/coverage/ +reports/mutation/ # Performance audit results lighthouse-reports/ diff --git a/src/app/admin/content/[modelCode]/[itemId]/page.tsx b/src/app/admin/content/[modelCode]/[itemId]/page.tsx index 81b2a35..61b4f77 100644 --- a/src/app/admin/content/[modelCode]/[itemId]/page.tsx +++ b/src/app/admin/content/[modelCode]/[itemId]/page.tsx @@ -1,11 +1,32 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { useAuth } from '@/components/admin/auth-context'; import { adminApi } from '@/lib/admin-api'; import { toast } from '@/components/ui/sonner'; -import { ArrowLeft, Save, X } from 'lucide-react'; +import { + ArrowLeft, + Save, + X, + AlertTriangle, + CheckCircle, + Cloud, + CloudOff, + Loader2, + Send, + FileText, +} from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; const MODEL_LABELS: Record = { news: '新闻资讯', @@ -36,16 +57,21 @@ interface ModelData { fields: FieldDef[]; } +// 自动保存延迟(毫秒) +const AUTO_SAVE_DELAY = 3000; + function TagInput({ id, value, onChange, placeholder, + error, }: { id: string; value: unknown; onChange: (tags: string[]) => void; placeholder?: string; + error?: string; }) { const tags = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : []; const [input, setInput] = useState(''); @@ -75,39 +101,54 @@ function TagInput({ }; return ( -
- {tags.map((tag) => ( - - {tag} - - - ))} - setInput(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={addTag} - placeholder={tags.length === 0 ? placeholder || '输入标签,按回车添加' : ''} - className="flex-1 min-w-[120px] outline-none bg-transparent text-sm" - /> + {tag} + + + ))} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={addTag} + placeholder={tags.length === 0 ? placeholder || '输入标签,按回车添加' : ''} + className="flex-1 min-w-[120px] outline-none bg-transparent text-sm" + /> +
+ {error &&

{error}

} ); } +function FieldError({ error }: { error?: string }) { + if (!error) return null; + return ( +

+ + {error} +

+ ); +} + export default function ContentEditorPage() { const params = useParams(); const modelCode = params.modelCode as string; @@ -124,9 +165,139 @@ export default function ContentEditorPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [validationErrors, setValidationErrors] = useState>({}); + + // 自动保存状态 + const [lastSaved, setLastSaved] = useState(null); + const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'unsaved'>('idle'); + const autoSaveTimerRef = useRef | null>(null); + const isInitialMountRef = useRef(true); + const hasUnsavedChangesRef = useRef(false); + + // 发布确认对话框 + const [publishDialogOpen, setPublishDialogOpen] = useState(false); + const [publishPending, setPublishPending] = useState(false); const modelLabel = MODEL_LABELS[modelCode] || modelCode; + // 表单验证 + const validate = useCallback((): Record => { + const errors: Record = {}; + if (!title.trim()) { + errors.title = '标题不能为空'; + } + if (slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) { + errors.slug = 'Slug 格式不正确,请使用小写字母、数字和连字符'; + } + // 验证模型必填字段 + if (model) { + model.fields.forEach((field) => { + if (field.required) { + const value = formData[field.name]; + if (value === undefined || value === null || value === '') { + errors[field.name] = `${field.label} 为必填项`; + } + } + }); + } + return errors; + }, [title, slug, formData, model]); + + // 实际保存操作 + const performSave = useCallback(async (newStatus?: string): Promise => { + const targetStatus = newStatus ?? status; + setSaving(true); + setError(''); + + try { + if (isNew) { + await adminApi.createItem({ + modelId: model?.id, + modelCode, + title: title.trim(), + slug: slug.trim() || undefined, + status: targetStatus, + data: formData, + }); + // 新建完成后不设置 autoSaveStatus,因为会导航离开 + return true; + } else { + await adminApi.updateItem(itemId, { + title: title.trim(), + slug: slug.trim() || undefined, + status: targetStatus, + data: formData, + }); + setLastSaved(new Date()); + setAutoSaveStatus('saved'); + hasUnsavedChangesRef.current = false; + return true; + } + } catch (err) { + const message = err instanceof Error ? err.message : '保存失败'; + setError(message); + toast.error(message); + setAutoSaveStatus('unsaved'); + return false; + } finally { + setSaving(false); + } + }, [isNew, itemId, model, modelCode, title, slug, status, formData]); + + // 自动保存(仅编辑已有内容时) + const triggerAutoSave = useCallback(() => { + if (isNew) return; + const errors = validate(); + if (Object.keys(errors).length > 0) { + // 有验证错误时不自动保存,但标记为未保存 + setAutoSaveStatus('unsaved'); + return; + } + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + autoSaveTimerRef.current = setTimeout(() => { + setAutoSaveStatus('saving'); + performSave().catch(() => { + // 错误已在 performSave 中处理 + }); + }, AUTO_SAVE_DELAY); + }, [isNew, validate, performSave]); + + // 标题/Slug/表单数据变更时触发自动保存 + useEffect(() => { + if (isInitialMountRef.current) { + isInitialMountRef.current = false; + return; + } + if (isNew) return; + setAutoSaveStatus('unsaved'); + hasUnsavedChangesRef.current = true; + triggerAutoSave(); + }, [title, slug, formData, isNew, triggerAutoSave]); + + // 清理定时器 + useEffect(() => { + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + }; + }, []); + + // 页面离开提醒 + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChangesRef.current && !isNew) { + e.preventDefault(); + e.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isNew]); + + // 加载数据 useEffect(() => { if (!authLoading && !user) { router.push('/admin/login'); @@ -136,21 +307,18 @@ export default function ContentEditorPage() { const load = async () => { try { - // 获取模型定义 const modelsRes = await adminApi.getModels(); const models = (modelsRes as ModelData[]) || []; const found = models.find((m) => m.code === modelCode); if (!found) throw new Error('模型未找到'); setModel(found); - // 初始化默认值 const defaults: Record = {}; found.fields.forEach((f) => { if (f.defaultValue !== undefined) defaults[f.name] = f.defaultValue; }); setFormData(defaults); - // 如果是编辑,加载现有数据 if (!isNew) { const itemsRes = await adminApi.getItems({ modelCode, page: 1, pageSize: 100 }); const items = (itemsRes as { items: Array<{ id: string; title: string; slug: string; status: string; data: Record }> }).items || []; @@ -162,6 +330,11 @@ export default function ContentEditorPage() { setFormData(item.data || {}); } } + + // 初始加载完成后,允许后续变更触发自动保存 + setTimeout(() => { + isInitialMountRef.current = false; + }, 0); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { @@ -173,50 +346,98 @@ export default function ContentEditorPage() { }, [user, authLoading, router, modelCode, itemId, isNew]); const handleFieldChange = (name: string, value: unknown) => { + // 清除该字段的验证错误 + setValidationErrors((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); setFormData((prev) => ({ ...prev, [name]: value })); }; - const handleSave = async () => { - if (!title.trim()) { - toast.error('请输入标题'); + // 字段失焦验证 + const handleFieldBlur = (fieldName: string) => { + if (!model) return; + const field = model.fields.find((f) => f.name === fieldName); + if (!field?.required) return; + const value = formData[fieldName]; + if (value === undefined || value === null || value === '') { + setValidationErrors((prev) => ({ + ...prev, + [fieldName]: `${field.label} 为必填项`, + })); + } + }; + + // 保存并返回列表 + const handleSaveAndBack = async () => { + const errors = validate(); + setValidationErrors(errors); + if (Object.keys(errors).length > 0) { + toast.error('请修正表单中的错误'); return; } - setSaving(true); - setError(''); - - try { - if (isNew) { - await adminApi.createItem({ - modelId: model?.id, - modelCode, - title: title.trim(), - slug: slug.trim() || undefined, - status, - data: formData, - }); - toast.success(`${modelLabel}创建成功`); - } else { - // 编辑时不提交 status,状态变更需通过工作流接口处理 - await adminApi.updateItem(itemId, { - title: title.trim(), - slug: slug.trim() || undefined, - data: formData, - }); - toast.success(`${modelLabel}保存成功`); - } + const success = await performSave(); + if (success) { + toast.success(isNew ? `${modelLabel}创建成功` : `${modelLabel}保存成功`); router.push(`/admin/content/${modelCode}`); - } catch (err) { - const message = err instanceof Error ? err.message : '保存失败'; - setError(message); - toast.error(message); - setSaving(false); + } + }; + + // 仅保存(不导航) + const handleSaveOnly = async () => { + const errors = validate(); + setValidationErrors(errors); + if (Object.keys(errors).length > 0) { + toast.error('请修正表单中的错误'); + return; + } + + if (isNew) { + // 新建内容直接保存并跳转 + const success = await performSave(); + if (success) { + toast.success(`${modelLabel}创建成功`); + router.push(`/admin/content/${modelCode}`); + } + } else { + await performSave(); + if (autoSaveStatus === 'saved') { + toast.success('已保存'); + } + } + }; + + // 点击发布按钮 + const handlePublishClick = () => { + const errors = validate(); + setValidationErrors(errors); + if (Object.keys(errors).length > 0) { + toast.error('请修正表单中的错误后再发布'); + return; + } + setPublishDialogOpen(true); + }; + + // 确认发布 + const handlePublishConfirm = async () => { + setPublishDialogOpen(false); + setPublishPending(true); + const success = await performSave('published'); + setPublishPending(false); + if (success) { + toast.success(`${modelLabel}已发布`); + if (isNew) { + router.push(`/admin/content/${modelCode}`); + } } }; const renderField = (field: FieldDef, prefix = '') => { const key = prefix + field.name; const value = formData[field.name]; + const error = validationErrors[field.name]; switch (field.type) { case 'text': @@ -234,9 +455,13 @@ export default function ContentEditorPage() { type="text" value={(value as string) || ''} onChange={(e) => handleFieldChange(field.name, e.target.value)} + onBlur={() => handleFieldBlur(field.name)} placeholder={field.placeholder} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" + className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${ + error ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} /> + ); @@ -254,10 +479,14 @@ export default function ContentEditorPage() { id={key} value={(value as string) || ''} onChange={(e) => handleFieldChange(field.name, e.target.value)} + onBlur={() => handleFieldBlur(field.name)} placeholder={field.placeholder} rows={4} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-y" + className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-y ${ + error ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} /> + ); @@ -273,8 +502,11 @@ export default function ContentEditorPage() { type="number" value={(value as number) ?? ''} onChange={(e) => handleFieldChange(field.name, parseFloat(e.target.value) || 0)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" + className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${ + error ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} /> + ); @@ -333,6 +565,7 @@ export default function ContentEditorPage() { value={value} onChange={(tags) => handleFieldChange(field.name, tags)} placeholder={field.placeholder} + error={error} /> ); @@ -404,7 +637,12 @@ export default function ContentEditorPage() {
- +
+ {/* 自动保存状态指示器 */} + {!isNew && ( +
+ {autoSaveStatus === 'saving' && ( + + + 保存中... + + )} + {autoSaveStatus === 'saved' && ( + + + 已保存 + {lastSaved && ( + + {lastSaved.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} + + )} + + )} + {autoSaveStatus === 'unsaved' && ( + + + 未保存 + + )} + {autoSaveStatus === 'idle' && ( + + + + )} +
+ )} + + {/* 仅保存按钮 */} + {!isNew && ( + + )} + + {/* 发布按钮 */} + {status !== 'published' && ( + + )} + + {/* 保存并返回 */} + +
{error && ( @@ -432,6 +735,21 @@ export default function ContentEditorPage() { )} + {/* 验证错误汇总 */} + {Object.keys(validationErrors).length > 0 && ( +
+
+ + 请修正以下错误 +
+
    + {Object.entries(validationErrors).map(([field, msg]) => ( +
  • {msg}
  • + ))} +
+
+ )} + {/* Form */}
{/* 标题 */} @@ -443,10 +761,25 @@ export default function ContentEditorPage() { id="title" type="text" value={title} - onChange={(e) => setTitle(e.target.value)} + onChange={(e) => { + setTitle(e.target.value); + setValidationErrors((prev) => { + const next = { ...prev }; + delete next.title; + return next; + }); + }} + onBlur={() => { + if (!title.trim()) { + setValidationErrors((prev) => ({ ...prev, title: '标题不能为空' })); + } + }} placeholder="请输入标题" - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" + className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${ + validationErrors.title ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} /> +
{/* Slug */} @@ -456,10 +789,28 @@ export default function ContentEditorPage() { id="slug" type="text" value={slug} - onChange={(e) => setSlug(e.target.value)} + onChange={(e) => { + setSlug(e.target.value); + setValidationErrors((prev) => { + const next = { ...prev }; + delete next.slug; + return next; + }); + }} + onBlur={() => { + if (slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) { + setValidationErrors((prev) => ({ + ...prev, + slug: 'Slug 格式不正确,请使用小写字母、数字和连字符', + })); + } + }} placeholder="URL 友好标识(留空自动生成)" - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" + className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${ + validationErrors.slug ? 'border-red-300 bg-red-50' : 'border-gray-300' + }`} /> + {/* 状态 */} @@ -481,17 +832,62 @@ export default function ContentEditorPage() { {model?.fields?.map((field) => renderField(field))} - {/* Bottom save */} -
- + {/* Bottom actions */} +
+
+ + {modelCode} / {isNew ? '新建' : itemId} +
+
+ {/* 仅保存按钮(底部) */} + {!isNew && ( + + )} + + {/* 保存并返回(底部) */} + +
+ + {/* 发布确认对话框 */} + + + + 确认发布 + + 将 {title || '无标题'} 发布为公开内容。发布后所有用户均可访问该内容。 + + + {Object.keys(validationErrors).length > 0 && ( +
+ 注意:表单中存在 {Object.keys(validationErrors).length} 个错误,发布可能会失败。 +
+ )} + + 取消 + + 确认发布 + + +
+
); } \ No newline at end of file diff --git a/src/app/admin/notifications/page.tsx b/src/app/admin/notifications/page.tsx new file mode 100644 index 0000000..1927ed1 --- /dev/null +++ b/src/app/admin/notifications/page.tsx @@ -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 = { + review_pending: '待审核', + review_approved: '审核通过', + review_rejected: '审核驳回', + item_archived: '已归档', +}; + +const NOTIFICATION_TYPE_ICONS: Record = { + review_pending: , + review_approved: , + review_rejected: , + item_archived: , +}; + +const NOTIFICATION_TYPE_COLORS: Record = { + 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([]); + 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 ( +
+
+
+ ); + } + + return ( +
+
+
+

通知中心

+

+ 共 {total} 条通知 + {unreadCount > 0 && ( + + {unreadCount} 条未读 + + )} +

+
+
+ + {unreadCount > 0 && ( + + )} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Notifications list */} +
+ {loading ? ( +
+
+
+ 加载中... +
+
+ ) : notifications.length === 0 ? ( +
+ +

暂无通知

+ {unreadOnly && ( +

没有未读通知,试试查看全部

+ )} +
+ ) : ( + notifications.map((item) => ( +
+ {/* Unread indicator */} + {!item.read && ( +
+ )} + + {/* Type icon */} +
+ {NOTIFICATION_TYPE_ICONS[item.type] || } +
+ + {/* Content */} +
+
+ + {NOTIFICATION_TYPE_LABELS[item.type] || item.type} + + + + {new Date(item.createdAt).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + +
+

+ {item.title} +

+

+ {item.message} +

+ + {/* Actions */} +
+ {!item.read && ( + + )} + {item.relatedItemId && item.relatedModelCode && ( + + )} +
+
+
+ )) + )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + 共 {total} 条,第 {page}/{totalPages} 页 + +
+ + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4f5a471..35ae47d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 = { + 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 = { + draft: '草稿', + review: '待审核', + published: '已发布', + archived: '已归档', +}; + +const STATUS_COLORS: Record = { + 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 = { + review_pending: , + review_approved: , + review_rejected: , + item_archived: , +}; + 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(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('/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 (
@@ -47,45 +127,176 @@ export default function AdminDashboardPage() {

欢迎回来,{user?.nickname || user?.username}

- {/* Stats */} -
+ {/* Stats cards */} +
{[ - { 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) => ( -
+
-

{stat.label}

-

+

{stat.label}

+

{fetching ? '-' : stat.value}

-
- +
+
))}
+
+ {/* Content status distribution */} +
+

内容状态分布

+ {fetching ? ( +
加载中...
+ ) : !stats?.statusCounts?.length ? ( +
暂无数据
+ ) : ( +
+ {stats.statusCounts.map((s) => ( +
+ + {STATUS_LABELS[s.status] || s.status} + + {s.count} +
+ ))} +
+ )} +
+ + {/* Recent notifications */} +
+
+

最近通知

+ +
+ {fetching ? ( +
加载中...
+ ) : !stats?.recentNotifications?.length ? ( +
+ + 暂无通知 +
+ ) : ( +
+ {stats.recentNotifications.map((n) => ( +
+
+ {NOTIFICATION_TYPE_ICONS[n.type] || } +
+
+

+ {n.title} +

+

+ + {new Date(n.createdAt).toLocaleDateString('zh-CN')} +

+
+ {!n.read &&
} +
+ ))} +
+ )} +
+ + {/* Recent content updates */} +
+

最近更新

+ {fetching ? ( +
加载中...
+ ) : !stats?.recentItems?.length ? ( +
暂无更新
+ ) : ( +
+ {stats.recentItems.map((item) => ( + + ))} +
+ )} +
+
+ {/* Quick links */}

内容管理

- {modelCards.map((card) => ( - - ))} + {modelCards.map((card) => { + const count = stats?.modelItemCounts?.find((m) => m.modelCode === card.code)?.count; + return ( + + ); + })}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..7f6fe76 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,540 @@ +'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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Plus, Edit, Trash2, Search, UserCog, Shield, Mail, Calendar } from 'lucide-react'; + +interface UserData { + id: string; + username: string; + nickname: string; + email: string; + phone: string; + status: number; + role: string; + roles: Array<{ code: string; name: string }>; + createdAt: string; + updatedAt: string; +} + +interface RoleData { + code: string; + name: string; + builtin: boolean; +} + +const ROLE_LABELS: Record = { + super_admin: '超级管理员', + content_admin: '内容管理员', + content_editor: '内容编辑', + reviewer: '审核员', + readonly: '只读用户', +}; + +const ROLE_COLORS: Record = { + super_admin: 'bg-red-100 text-red-700', + content_admin: 'bg-blue-100 text-blue-700', + content_editor: 'bg-green-100 text-green-700', + reviewer: 'bg-amber-100 text-amber-700', + readonly: 'bg-gray-100 text-gray-700', +}; + +export default function UsersPage() { + const { user, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [roles, setRoles] = useState([]); + const pageSize = 20; + + // Create/Edit dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [formData, setFormData] = useState({ + username: '', + password: '', + nickname: '', + email: '', + phone: '', + roleCodes: [] as string[], + }); + const [saving, setSaving] = useState(false); + + // Delete dialog state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(''); + try { + const query = new URLSearchParams(); + query.set('page', String(page)); + query.set('pageSize', String(pageSize)); + if (search) query.set('search', search); + + const data = await adminApi.request<{ + users: UserData[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }>(`/api/admin/users?${query.toString()}`); + setUsers(data.users || []); + setTotal(data.total || 0); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, [page, search]); + + const fetchRoles = useCallback(async () => { + try { + const data = await adminApi.getRoles(); + setRoles(data.roles || []); + } catch { + // Roles fetch is secondary + } + }, []); + + useEffect(() => { + if (!authLoading && !user) { + router.push('/admin/login'); + return; + } + if (user) { + fetchUsers(); + fetchRoles(); + } + }, [user, authLoading, router, fetchUsers, fetchRoles]); + + const openCreateDialog = () => { + setEditingUser(null); + setFormData({ username: '', password: '', nickname: '', email: '', phone: '', roleCodes: [] }); + setDialogOpen(true); + }; + + const openEditDialog = (u: UserData) => { + setEditingUser(u); + setFormData({ + username: u.username, + password: '', + nickname: u.nickname, + email: u.email, + phone: u.phone, + roleCodes: u.roles.map((r) => r.code), + }); + setDialogOpen(true); + }; + + const handleSave = async () => { + if (editingUser) { + // Update + if (!formData.nickname && !formData.email) { + toast.error('请填写昵称或邮箱'); + return; + } + setSaving(true); + try { + const body: Record = { + nickname: formData.nickname, + email: formData.email, + phone: formData.phone, + roleCodes: formData.roleCodes, + }; + if (formData.password) body.password = formData.password; + await adminApi.request(`/api/admin/users?id=${editingUser.id}`, { + method: 'PUT', + body: JSON.stringify(body), + }); + toast.success('用户更新成功'); + setDialogOpen(false); + fetchUsers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : '更新失败'); + } finally { + setSaving(false); + } + } else { + // Create + if (!formData.username || !formData.password) { + toast.error('用户名和密码必填'); + return; + } + if (formData.username.length < 3) { + toast.error('用户名长度不能少于 3 位'); + return; + } + if (formData.password.length < 6) { + toast.error('密码长度不能少于 6 位'); + return; + } + setSaving(true); + try { + await adminApi.request('/api/admin/users', { + method: 'POST', + body: JSON.stringify(formData), + }); + toast.success('用户创建成功'); + setDialogOpen(false); + fetchUsers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : '创建失败'); + } finally { + setSaving(false); + } + } + }; + + const handleDelete = async () => { + if (!pendingDeleteId) return; + try { + await adminApi.request(`/api/admin/users?id=${pendingDeleteId}`, { method: 'DELETE' }); + toast.success('用户已删除'); + setDeleteDialogOpen(false); + setPendingDeleteId(null); + fetchUsers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : '删除失败'); + } + }; + + const toggleRole = (code: string) => { + setFormData((prev) => ({ + ...prev, + roleCodes: prev.roleCodes.includes(code) + ? prev.roleCodes.filter((r) => r !== code) + : [...prev.roleCodes, code], + })); + }; + + const totalPages = Math.ceil(total / pageSize); + + if (authLoading || (!user && typeof window !== 'undefined')) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

用户管理

+

管理系统用户与角色分配

+
+ +
+ + {/* Search */} +
+ + { setSearch(e.target.value); setPage(1); }} + className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ + {error && ( +
+ {error} +
+ )} + + {/* Users table */} +
+
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : users.length === 0 ? ( + + + + ) : ( + users.map((u) => ( + + + + + + + + + + )) + )} + +
用户名昵称邮箱角色状态创建时间操作
+
+
+ 加载中... +
+
+ 暂无用户数据 +
+
+ + {u.username} +
+
{u.nickname || '-'} + {u.email ? ( + + + {u.email} + + ) : ( + - + )} + +
+ {u.roles.map((r) => ( + + + {r.name || ROLE_LABELS[r.code] || r.code} + + ))} + {u.roles.length === 0 && ( + 无角色 + )} +
+
+ + {u.status === 1 ? '启用' : '禁用'} + + +
+ + {new Date(u.createdAt).toLocaleDateString('zh-CN')} +
+
+
+ + +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + 共 {total} 条,第 {page}/{totalPages} 页 + +
+ + +
+
+ )} +
+ + {/* Create/Edit Dialog */} + {dialogOpen && ( +
+
setDialogOpen(false)} /> +
+

+ {editingUser ? '编辑用户' : '创建用户'} +

+ +
+
+ + setFormData((p) => ({ ...p, username: e.target.value }))} + disabled={!!editingUser} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200 disabled:bg-gray-100 disabled:text-gray-400" + placeholder="3-32 位字母/数字/下划线" + /> +
+ +
+ + setFormData((p) => ({ ...p, password: e.target.value }))} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + placeholder={editingUser ? '留空保持原密码' : '至少 6 位'} + /> +
+ +
+
+ + setFormData((p) => ({ ...p, nickname: e.target.value }))} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+
+ + setFormData((p) => ({ ...p, email: e.target.value }))} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+
+ +
+ + setFormData((p) => ({ ...p, phone: e.target.value }))} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ +
+ +
+ {roles.map((r) => ( + + ))} + {roles.length === 0 && ( + 加载角色中... + )} +
+
+
+ +
+ + +
+
+
+ )} + + {/* Delete confirmation */} + + + + 确认删除 + + 删除后该用户将无法登录系统,此操作不可撤销。 + + + + 取消 + + 确认删除 + + + + +
+ ); +} \ No newline at end of file diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..9bbc41e --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -0,0 +1,82 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth'; +import { success, unauthorized, internalError } from '@/lib/api-response'; + +// GET /api/admin/stats - 获取仪表盘统计数据 +export async function GET(request: NextRequest) { + const user = authenticateRequest(request); + if (!user) return unauthorized(); + + try { + const [modelCount, itemCount, zoneCount, userCount, pendingReviewCount, unreadCount] = await Promise.all([ + prisma.contentModel.count(), + prisma.contentItem.count(), + prisma.contentZone.count(), + prisma.user.count({ where: { status: 1 } }), + prisma.contentItem.count({ where: { status: 'review' } }), + prisma.notification.count({ where: { userId: user.userId, read: false } }), + ]); + + // 获取每个内容模型的条目数 + const modelItemCounts = await prisma.contentItem.groupBy({ + by: ['modelCode'], + _count: { modelCode: true }, + }); + + // 获取不同状态的条目数 + const statusCounts = await prisma.contentItem.groupBy({ + by: ['status'], + _count: { status: true }, + }); + + // 获取最近 5 条通知 + const recentNotifications = await prisma.notification.findMany({ + where: { userId: user.userId }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + type: true, + title: true, + read: true, + createdAt: true, + }, + }); + + // 获取最近 5 条更新的内容 + const recentItems = await prisma.contentItem.findMany({ + orderBy: { updatedAt: 'desc' }, + take: 5, + select: { + id: true, + title: true, + modelCode: true, + status: true, + updatedAt: true, + }, + }); + + return success({ + models: modelCount, + items: itemCount, + zones: zoneCount, + activeUsers: userCount, + pendingReviews: pendingReviewCount, + unreadNotifications: unreadCount, + modelItemCounts: modelItemCounts.map((m) => ({ + modelCode: m.modelCode, + count: m._count.modelCode, + })), + statusCounts: statusCounts.map((s) => ({ + status: s.status, + count: s._count.status, + })), + recentNotifications, + recentItems, + }); + } catch (error) { + console.error('Get stats error:', error); + return internalError(); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..b7d9ff7 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,263 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/db'; +import { authenticateRequest, hashPassword } from '@/lib/auth'; +import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; + +// GET /api/admin/users - 获取用户列表 +export async function GET(request: NextRequest) { + const user = authenticateRequest(request); + if (!user) return unauthorized(); + + const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } }); + const roleCodes = userRoles.map((ur) => ur.roleCode); + if (!roleCodes.includes('super_admin') && !roleCodes.includes('content_admin')) { + return forbidden('仅管理员可查看用户列表'); + } + + try { + const { searchParams } = new URL(request.url); + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); + const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10))); + const search = searchParams.get('search') || ''; + + const where = search + ? { + OR: [ + { username: { contains: search } }, + { nickname: { contains: search } }, + { email: { contains: search } }, + ], + } + : {}; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + select: { + id: true, + username: true, + nickname: true, + email: true, + phone: true, + status: true, + createdAt: true, + updatedAt: true, + }, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { createdAt: 'desc' }, + }), + prisma.user.count({ where }), + ]); + + // 获取每个用户的角色 + const usersWithRoles = await Promise.all( + users.map(async (u) => { + const roles = await prisma.userRole.findMany({ + where: { userId: u.id }, + include: { role: { select: { code: true, name: true } } }, + }); + return { + ...u, + roles: roles.map((r) => ({ code: r.role.code, name: r.role.name })), + // 保留向后兼容 + role: roles[0]?.role?.code || 'readonly', + }; + }), + ); + + return success({ + users: usersWithRoles, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } catch (error) { + console.error('Get users error:', error); + return internalError(); + } +} + +// POST /api/admin/users - 创建用户 +export async function POST(request: NextRequest) { + const user = authenticateRequest(request); + if (!user) return unauthorized(); + + const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } }); + const roleCodes = userRoles.map((ur) => ur.roleCode); + if (!roleCodes.includes('super_admin') && !roleCodes.includes('content_admin')) { + return forbidden('仅管理员可创建用户'); + } + + try { + const body = await request.json() as { + username: string; + password: string; + nickname?: string; + email?: string; + phone?: string; + roleCodes?: string[]; + }; + + if (!body.username || !body.password) { + return validationError('用户名和密码必填'); + } + + if (body.username.length < 3 || body.username.length > 32) { + return validationError('用户名长度需在 3-32 字符之间'); + } + + if (body.password.length < 6) { + return validationError('密码长度不能少于 6 位'); + } + + // 检查用户名是否已存在 + const existing = await prisma.user.findUnique({ where: { username: body.username } }); + if (existing) { + return validationError('用户名已存在'); + } + + const hashedPassword = await hashPassword(body.password); + const newUser = await prisma.user.create({ + data: { + username: body.username, + password: hashedPassword, + nickname: body.nickname || '', + email: body.email || '', + phone: body.phone || '', + status: 1, + }, + }); + + // 分配角色 + const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly']; + for (const roleCode of rolesToAssign) { + const role = await prisma.role.findUnique({ where: { code: roleCode } }); + if (role) { + await prisma.userRole.create({ + data: { userId: newUser.id, roleCode }, + }); + } + } + + return success({ + id: newUser.id, + username: newUser.username, + nickname: newUser.nickname, + email: newUser.email, + phone: newUser.phone, + roles: rolesToAssign, + }, 201); + } catch (error) { + console.error('Create user error:', error); + return internalError(); + } +} + +// PUT /api/admin/users - 更新用户 +export async function PUT(request: NextRequest) { + const currentUser = authenticateRequest(request); + if (!currentUser) return unauthorized(); + + const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } }); + const roleCodes = userRoles.map((ur) => ur.roleCode); + if (!roleCodes.includes('super_admin') && !roleCodes.includes('content_admin')) { + return forbidden('仅管理员可编辑用户'); + } + + try { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('id'); + if (!userId) return validationError('用户 ID 必填'); + + const body = await request.json() as { + nickname?: string; + email?: string; + phone?: string; + password?: string; + status?: number; + roleCodes?: string[]; + }; + + const updateData: Record = {}; + if (body.nickname !== undefined) updateData.nickname = body.nickname; + if (body.email !== undefined) updateData.email = body.email; + if (body.phone !== undefined) updateData.phone = body.phone; + if (body.status !== undefined) updateData.status = body.status; + if (body.password) { + updateData.password = await hashPassword(body.password); + } + + await prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + // 更新角色分配 + if (body.roleCodes) { + // 防止将自己从 super_admin 移除 + if (userId === currentUser.userId) { + const currentRoles = await prisma.userRole.findMany({ where: { userId } }); + const currentIsSuperAdmin = currentRoles.some((r) => r.roleCode === 'super_admin'); + const stillHasSuperAdmin = body.roleCodes.includes('super_admin'); + if (currentIsSuperAdmin && !stillHasSuperAdmin) { + return forbidden('不能移除自己的超级管理员角色'); + } + } + + await prisma.userRole.deleteMany({ where: { userId } }); + for (const roleCode of body.roleCodes) { + const role = await prisma.role.findUnique({ where: { code: roleCode } }); + if (role) { + await prisma.userRole.create({ data: { userId, roleCode } }); + } + } + } + + return success({ id: userId }); + } catch (error) { + console.error('Update user error:', error); + return internalError(); + } +} + +// DELETE /api/admin/users - 删除用户 +export async function DELETE(request: NextRequest) { + const currentUser = authenticateRequest(request); + if (!currentUser) return unauthorized(); + + const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } }); + const roleCodes = userRoles.map((ur) => ur.roleCode); + if (!roleCodes.includes('super_admin')) { + return forbidden('仅超级管理员可删除用户'); + } + + try { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('id'); + if (!userId) return validationError('用户 ID 必填'); + + if (userId === currentUser.userId) { + return forbidden('不能删除自己的账号'); + } + + // 检查是否是最后一个 super_admin + const targetRoles = await prisma.userRole.findMany({ where: { userId } }); + const isTargetSuperAdmin = targetRoles.some((r) => r.roleCode === 'super_admin'); + if (isTargetSuperAdmin) { + const superAdminCount = await prisma.userRole.count({ where: { roleCode: 'super_admin' } }); + if (superAdminCount <= 1) { + return forbidden('不能删除最后一个超级管理员'); + } + } + + await prisma.userRole.deleteMany({ where: { userId } }); + await prisma.user.delete({ where: { id: userId } }); + + return success({ deleted: true }); + } catch (error) { + console.error('Delete user error:', error); + return internalError(); + } +} \ No newline at end of file diff --git a/src/components/admin/admin-layout.tsx b/src/components/admin/admin-layout.tsx index cdae5b1..da1df21 100644 --- a/src/components/admin/admin-layout.tsx +++ b/src/components/admin/admin-layout.tsx @@ -22,6 +22,7 @@ import { Phone, Shield, UserCog, + Bell, } from 'lucide-react'; const menuItems = [ @@ -66,9 +67,17 @@ const menuItems = [ label: '系统管理', icon: Shield, children: [ + { label: '用户管理', href: '/admin/users', icon: Users }, { label: '角色权限', href: '/admin/roles', icon: UserCog }, ], }, + { + label: '通知', + icon: Bell, + children: [ + { label: '通知中心', href: '/admin/notifications', icon: Bell }, + ], + }, ]; export function AdminLayout({ children }: { children: React.ReactNode }) { diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts index 0aa9666..be90f4e 100644 --- a/src/lib/admin-api.ts +++ b/src/lib/admin-api.ts @@ -8,7 +8,7 @@ class AdminApiClient { return localStorage.getItem('novalon_admin_token'); } - private async request( + async request( path: string, options: RequestInit & { encrypt?: boolean } = {}, ): Promise {