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
+1
View File
@@ -256,6 +256,7 @@ heading-hierarchy-report.json
reports/e2e/
reports/performance/
reports/coverage/
reports/mutation/
# Performance audit results
lighthouse-reports/
@@ -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<string, string> = {
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 (
<div
id={id}
className="w-full min-h-[42px] px-3 py-2 border border-gray-300 rounded-lg text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-900 focus-within:border-transparent flex flex-wrap gap-2"
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="text-gray-400 hover:text-gray-600"
aria-label={`移除标签 ${tag}`}
<div>
<div
id={id}
className={`w-full min-h-[42px] px-3 py-2 border rounded-lg text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-900 focus-within:border-transparent flex flex-wrap gap-2 ${
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
}`}
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={input}
onChange={(e) => 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}
<button
type="button"
onClick={() => removeTag(tag)}
className="text-gray-400 hover:text-gray-600"
aria-label={`移除标签 ${tag}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={input}
onChange={(e) => 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"
/>
</div>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
function FieldError({ error }: { error?: string }) {
if (!error) return null;
return (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{error}
</p>
);
}
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<Record<string, string>>({});
// 自动保存状态
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'unsaved'>('idle');
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, string> => {
const errors: Record<string, string> = {};
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<boolean> => {
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<string, unknown> = {};
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<string, unknown> }> }).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'
}`}
/>
<FieldError error={error} />
</div>
);
@@ -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'
}`}
/>
<FieldError error={error} />
</div>
);
@@ -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'
}`}
/>
<FieldError error={error} />
</div>
);
@@ -333,6 +565,7 @@ export default function ContentEditorPage() {
value={value}
onChange={(tags) => handleFieldChange(field.name, tags)}
placeholder={field.placeholder}
error={error}
/>
</div>
);
@@ -404,7 +637,12 @@ export default function ContentEditorPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => router.push(`/admin/content/${modelCode}`)}
onClick={() => {
if (hasUnsavedChangesRef.current && !isNew) {
if (!confirm('有未保存的更改,确定要离开吗?')) return;
}
router.push(`/admin/content/${modelCode}`);
}}
className="p-2 rounded hover:bg-gray-100 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
@@ -416,14 +654,79 @@ export default function ContentEditorPage() {
<p className="text-sm text-gray-500 mt-1">{model?.name}</p>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : '保存'}
</button>
<div className="flex items-center gap-3">
{/* 自动保存状态指示器 */}
{!isNew && (
<div className="flex items-center gap-1.5 text-xs">
{autoSaveStatus === 'saving' && (
<span className="text-amber-600 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
...
</span>
)}
{autoSaveStatus === 'saved' && (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
{lastSaved && (
<span className="text-gray-400">
{lastSaved.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</span>
)}
{autoSaveStatus === 'unsaved' && (
<span className="text-amber-600 flex items-center gap-1">
<CloudOff className="w-3 h-3" />
</span>
)}
{autoSaveStatus === 'idle' && (
<span className="text-gray-400 flex items-center gap-1">
<Cloud className="w-3 h-3" />
</span>
)}
</div>
)}
{/* 仅保存按钮 */}
{!isNew && (
<button
onClick={handleSaveOnly}
disabled={saving}
className="flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
</button>
)}
{/* 发布按钮 */}
{status !== 'published' && (
<button
onClick={handlePublishClick}
disabled={saving || publishPending}
className="flex items-center gap-2 px-4 py-2 bg-green-700 text-white rounded-lg text-sm font-medium hover:bg-green-600 disabled:opacity-50 transition-colors"
>
{publishPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
)}
{/* 保存并返回 */}
<button
onClick={handleSaveAndBack}
disabled={saving}
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : isNew ? '创建' : '保存并返回'}
</button>
</div>
</div>
{error && (
@@ -432,6 +735,21 @@ export default function ContentEditorPage() {
</div>
)}
{/* 验证错误汇总 */}
{Object.keys(validationErrors).length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 text-amber-700 text-sm font-medium mb-1">
<AlertTriangle className="w-4 h-4" />
</div>
<ul className="list-disc list-inside text-xs text-amber-600 space-y-0.5">
{Object.entries(validationErrors).map(([field, msg]) => (
<li key={field}>{msg}</li>
))}
</ul>
</div>
)}
{/* Form */}
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-5">
{/* 标题 */}
@@ -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'
}`}
/>
<FieldError error={validationErrors.title} />
</div>
{/* 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'
}`}
/>
<FieldError error={validationErrors.slug} />
</div>
{/* 状态 */}
@@ -481,17 +832,62 @@ export default function ContentEditorPage() {
{model?.fields?.map((field) => renderField(field))}
</div>
{/* Bottom save */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : '保存'}
</button>
{/* Bottom actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-gray-400">
<FileText className="w-3.5 h-3.5" />
{modelCode} / {isNew ? '新建' : itemId}
</div>
<div className="flex items-center gap-3">
{/* 仅保存按钮(底部) */}
{!isNew && (
<button
onClick={handleSaveOnly}
disabled={saving}
className="flex items-center gap-2 px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
</button>
)}
{/* 保存并返回(底部) */}
<button
onClick={handleSaveAndBack}
disabled={saving}
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : isNew ? '创建' : '保存并返回'}
</button>
</div>
</div>
{/* 发布确认对话框 */}
<AlertDialog open={publishDialogOpen} onOpenChange={setPublishDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<strong>{title || '无标题'}</strong> 访
</AlertDialogDescription>
</AlertDialogHeader>
{Object.keys(validationErrors).length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-xs text-amber-700">
{Object.keys(validationErrors).length}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handlePublishConfirm}
className="bg-green-700 hover:bg-green-600 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+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>
);
}
+240 -29
View File
@@ -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>
+540
View File
@@ -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<string, string> = {
super_admin: '超级管理员',
content_admin: '内容管理员',
content_editor: '内容编辑',
reviewer: '审核员',
readonly: '只读用户',
};
const ROLE_COLORS: Record<string, string> = {
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<UserData[]>([]);
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<RoleData[]>([]);
const pageSize = 20;
// Create/Edit dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserData | null>(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<string | null>(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<string, unknown> = {
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 (
<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"></p>
</div>
<button
onClick={openCreateDialog}
className="flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg text-sm font-medium hover:bg-gray-800 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索用户名、昵称或邮箱..."
value={search}
onChange={(e) => { 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"
/>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* Users table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-gray-400">
<div className="flex items-center justify-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full" />
...
</div>
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-gray-400">
</td>
</tr>
) : (
users.map((u) => (
<tr key={u.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<UserCog className="w-4 h-4 text-gray-400" />
<span className="font-medium text-gray-900">{u.username}</span>
</div>
</td>
<td className="px-4 py-3 text-gray-600">{u.nickname || '-'}</td>
<td className="px-4 py-3">
{u.email ? (
<a href={`mailto:${u.email}`} className="text-gray-600 hover:text-gray-900 flex items-center gap-1">
<Mail className="w-3 h-3" />
{u.email}
</a>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{u.roles.map((r) => (
<span
key={r.code}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
ROLE_COLORS[r.code] || 'bg-gray-100 text-gray-700'
}`}
>
<Shield className="w-3 h-3" />
{r.name || ROLE_LABELS[r.code] || r.code}
</span>
))}
{u.roles.length === 0 && (
<span className="text-xs text-gray-400"></span>
)}
</div>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
u.status === 1
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{u.status === 1 ? '启用' : '禁用'}
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(u.createdAt).toLocaleDateString('zh-CN')}
</div>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openEditDialog(u)}
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
title="编辑"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => { setPendingDeleteId(u.id); setDeleteDialogOpen(true); }}
className="p-1.5 rounded hover:bg-red-50 text-gray-500 hover:text-red-600"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<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>
{/* Create/Edit Dialog */}
{dialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={() => setDialogOpen(false)} />
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 className="text-lg font-bold text-gray-900 mb-4">
{editingUser ? '编辑用户' : '创建用户'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={formData.username}
onChange={(e) => 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 位字母/数字/下划线"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{editingUser ? '新密码(留空不修改)' : '密码'}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => 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 位'}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={formData.nickname}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="tel"
value={formData.phone}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="flex flex-wrap gap-2">
{roles.map((r) => (
<button
key={r.code}
type="button"
onClick={() => toggleRole(r.code)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
formData.roleCodes.includes(r.code)
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
{r.name}
</button>
))}
{roles.length === 0 && (
<span className="text-xs text-gray-400">...</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
<button
onClick={() => setDialogOpen(false)}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{saving && <div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />}
{editingUser ? '保存更改' : '创建用户'}
</button>
</div>
</div>
</div>
)}
{/* Delete confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+82
View File
@@ -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();
}
}
+263
View File
@@ -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<string, unknown> = {};
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();
}
}
+9
View File
@@ -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 }) {
+1 -1
View File
@@ -8,7 +8,7 @@ class AdminApiClient {
return localStorage.getItem('novalon_admin_token');
}
private async request<T>(
async request<T>(
path: string,
options: RequestInit & { encrypt?: boolean } = {},
): Promise<T> {