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