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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user