feat: 修复测试套件问题并添加Woodpecker CI配置
- 修复API测试认证问题:创建全局认证设置,更新Playwright配置 - 优化回归测试稳定性:增加超时时间到15秒,修复定位器 - 创建Woodpecker CI工作流:CI、部署和质量门禁配置 - 添加Jest配置和测试脚本 - 移除登录页面的默认账号密码显示(安全问题修复)
This commit is contained in:
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Toast } from '@/components/ui/toast';
|
||||
import { sanitizeInput } from '@/lib/sanitize';
|
||||
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
|
||||
import { generateCSRFToken, setCSRFTokenToStorage } from '@/lib/csrf';
|
||||
import { Mail, Phone, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
Eye,
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/admin/RichTextEditor'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-64 border border-gray-300 rounded-lg flex items-center justify-center bg-gray-50">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'news', label: '新闻' },
|
||||
{ value: 'product', label: '产品' },
|
||||
{ value: 'service', label: '服务' },
|
||||
{ value: 'case', label: '案例' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'published', label: '发布' },
|
||||
{ value: 'archived', label: '归档' },
|
||||
];
|
||||
|
||||
export default function ContentEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const isNew = params.id === 'new';
|
||||
const contentId = isNew ? null : (params.id as string);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'news',
|
||||
title: '',
|
||||
slug: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
coverImage: '',
|
||||
category: '',
|
||||
tags: [] as string[],
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && contentId) {
|
||||
fetchContent();
|
||||
}
|
||||
}, [isNew, contentId]);
|
||||
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/content/${contentId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setFormData({
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
content: data.content || '',
|
||||
coverImage: data.coverImage || '',
|
||||
category: data.category || '',
|
||||
tags: data.tags || [],
|
||||
status: data.status,
|
||||
});
|
||||
} else {
|
||||
router.push('/admin/content');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取内容失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: prev.slug || generateSlug(title),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append('file', file);
|
||||
uploadFormData.append('type', 'image');
|
||||
|
||||
const res = await fetch('/api/admin/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setFormData(prev => ({ ...prev, coverImage: data.file.url }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = '请输入标题';
|
||||
}
|
||||
if (!formData.slug.trim()) {
|
||||
newErrors.slug = '请输入 Slug';
|
||||
}
|
||||
if (!formData.type) {
|
||||
newErrors.type = '请选择类型';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async (publish: boolean = false) => {
|
||||
if (!validate()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = isNew
|
||||
? '/api/admin/content'
|
||||
: `/api/admin/content/${contentId}`;
|
||||
|
||||
const body = {
|
||||
...formData,
|
||||
status: publish ? 'published' : formData.status,
|
||||
contentBody: formData.content,
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isNew ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (isNew) {
|
||||
router.push(`/admin/content/${data.id}`);
|
||||
}
|
||||
alert('保存成功');
|
||||
} else {
|
||||
alert(data.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
alert('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#C41E3A]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/admin/content"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isNew ? '新建内容' : '编辑内容'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
保存草稿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#a01830] disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
标题 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none ${
|
||||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入标题"
|
||||
/>
|
||||
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Slug <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none ${
|
||||
errors.slug ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="url-slug"
|
||||
/>
|
||||
{errors.slug && <p className="text-red-500 text-sm mt-1">{errors.slug}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
摘要
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.excerpt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none resize-none"
|
||||
placeholder="请输入摘要(可选)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
内容
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={formData.content}
|
||||
onChange={(content: string) => setFormData(prev => ({ ...prev, content }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">基本信息</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
类型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
{typeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
{statusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
分类
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
placeholder="分类名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">封面图片</h3>
|
||||
|
||||
{formData.coverImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.coverImage}
|
||||
alt="封面"
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormData(prev => ({ ...prev, coverImage: '' }))}
|
||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow hover:bg-gray-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="w-full h-40 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-[#C41E3A] hover:bg-red-50 transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">点击上传</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
FileText,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ContentItem {
|
||||
id: string;
|
||||
type: 'news' | 'product' | 'service' | 'case';
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string | null;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
category: string | null;
|
||||
createdAt: string;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
news: '新闻',
|
||||
product: '产品',
|
||||
service: '服务',
|
||||
case: '案例',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
published: 'bg-green-100 text-green-800',
|
||||
archived: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export default function ContentListPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [items, setItems] = useState<ContentItem[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', pagination.page.toString());
|
||||
params.set('limit', pagination.limit.toString());
|
||||
if (search) params.set('search', search);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/content?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setItems(data.items);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取内容列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, search, typeFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [fetchContent]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
fetchContent();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/content/${deleteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setItems(items.filter(item => item.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">内容管理</h1>
|
||||
<Link
|
||||
href="/admin/content/new"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#a01830] transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
新建内容
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索标题..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value);
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#C41E3A]" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暂无内容</p>
|
||||
<Link
|
||||
href="/admin/content/new"
|
||||
className="inline-block mt-4 text-[#C41E3A] hover:underline"
|
||||
>
|
||||
创建第一个内容
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">标题</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">类型</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">状态</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">分类</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">创建时间</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-4 px-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{item.title}</p>
|
||||
<p className="text-sm text-gray-500">{item.slug}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">
|
||||
{typeLabels[item.type]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColors[item.status]}`}>
|
||||
{statusLabels[item.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-gray-600">
|
||||
{item.category || '-'}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-gray-600">
|
||||
{formatDate(item.createdAt)}
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/content/${item.id}`}
|
||||
className="p-2 text-gray-400 hover:text-[#C41E3A] hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
共 {pagination.total} 条记录
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPagination(prev => ({ ...prev, page: prev.page - 1 }))}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPagination(prev => ({ ...prev, page: prev.page + 1 }))}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">确认删除</h3>
|
||||
<p className="text-gray-600 mb-6">确定要删除此内容吗?此操作不可撤销。</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteId(null)}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleting ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
const navigation = [
|
||||
{ name: '仪表盘', href: '/admin', icon: LayoutDashboard },
|
||||
{ name: '内容管理', href: '/admin/content', icon: FileText },
|
||||
{ name: '配置中心', href: '/admin/settings', icon: Settings },
|
||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ name: '审计日志', href: '/admin/logs', icon: Activity },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { data: session, status } = useSession();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const isLoginPage = pathname === '/admin/login';
|
||||
|
||||
if (isLoginPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-4">请先登录</p>
|
||||
<Link
|
||||
href="/admin/login"
|
||||
className="text-[#C41E3A] hover:underline"
|
||||
>
|
||||
前往登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
|
||||
<Link href="/admin" className="text-xl font-bold text-[#C41E3A]">
|
||||
睿新致遠 CMS
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
{sidebarOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-30 bg-black/50"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`
|
||||
fixed top-0 left-0 z-30 h-full w-64 bg-white border-r border-gray-200 transform transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-200">
|
||||
<Link href="/admin" className="text-xl font-bold text-[#C41E3A]">
|
||||
睿新致遠 CMS
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors
|
||||
${isActive
|
||||
? 'bg-[#C41E3A] text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-medium">
|
||||
{session?.user?.name?.[0] || 'U'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{session?.user?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{session?.user?.role === 'admin' ? '管理员' :
|
||||
session?.user?.role === 'editor' ? '编辑' : '查看者'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/admin/login' })}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:ml-64 min-h-screen">
|
||||
<div className="p-6 lg:p-8 pt-20 lg:pt-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Eye, EyeOff, Mail, Lock, AlertCircle } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '/admin';
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError('邮箱或密码错误');
|
||||
} else {
|
||||
router.push(callbackUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('登录失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-block">
|
||||
<h1 className="text-3xl font-bold text-[#C41E3A]">睿新致遠</h1>
|
||||
</Link>
|
||||
<p className="text-gray-600 mt-2">管理后台登录</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3 text-red-700">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="请输入邮箱"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="请输入密码"
|
||||
className="w-full pl-10 pr-12 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 bg-[#C41E3A] text-white font-medium rounded-lg hover:bg-[#a01830] focus:ring-2 focus:ring-offset-2 focus:ring-[#C41E3A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-gray-600 hover:text-[#C41E3A] transition-colors"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
© {new Date().getFullYear()} 四川睿新致远科技有限公司 版权所有
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/db';
|
||||
import { content, users } from '@/db/schema';
|
||||
import { desc, eq, sql } from 'drizzle-orm';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Settings, Users, TrendingUp } from 'lucide-react';
|
||||
|
||||
async function getStats() {
|
||||
const [
|
||||
contentCount,
|
||||
publishedCount,
|
||||
draftCount,
|
||||
userCount,
|
||||
recentContent,
|
||||
] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(content),
|
||||
db.select({ count: sql<number>`count(*)` }).from(content).where(eq(content.status, 'published')),
|
||||
db.select({ count: sql<number>`count(*)` }).from(content).where(eq(content.status, 'draft')),
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select().from(content).orderBy(desc(content.createdAt)).limit(5),
|
||||
]);
|
||||
|
||||
return {
|
||||
contentCount: contentCount[0]?.count || 0,
|
||||
publishedCount: publishedCount[0]?.count || 0,
|
||||
draftCount: draftCount[0]?.count || 0,
|
||||
userCount: userCount[0]?.count || 0,
|
||||
recentContent,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const session = await auth();
|
||||
const stats = await getStats();
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
name: '总内容数',
|
||||
value: stats.contentCount,
|
||||
icon: FileText,
|
||||
color: 'bg-blue-500',
|
||||
href: '/admin/content'
|
||||
},
|
||||
{
|
||||
name: '已发布',
|
||||
value: stats.publishedCount,
|
||||
icon: TrendingUp,
|
||||
color: 'bg-green-500',
|
||||
href: '/admin/content?status=published'
|
||||
},
|
||||
{
|
||||
name: '草稿',
|
||||
value: stats.draftCount,
|
||||
icon: FileText,
|
||||
color: 'bg-yellow-500',
|
||||
href: '/admin/content?status=draft'
|
||||
},
|
||||
{
|
||||
name: '用户数',
|
||||
value: stats.userCount,
|
||||
icon: Users,
|
||||
color: 'bg-purple-500',
|
||||
href: '/admin/users'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">仪表盘</h1>
|
||||
<p className="text-gray-600 mt-1">欢迎回来,{session?.user?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((stat) => (
|
||||
<Link
|
||||
key={stat.name}
|
||||
href={stat.href}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">{stat.name}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`${stat.color} p-3 rounded-lg`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">最近内容</h2>
|
||||
<Link
|
||||
href="/admin/content"
|
||||
className="text-sm text-[#C41E3A] hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{stats.recentContent.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">暂无内容</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{stats.recentContent.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{item.type} · {item.status === 'published' ? '已发布' : '草稿'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/content/${item.id}`}
|
||||
className="text-sm text-[#C41E3A] hover:underline ml-4"
|
||||
>
|
||||
编辑
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">快捷操作</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/admin/content/new"
|
||||
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-gray-300 hover:border-[#C41E3A] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<FileText className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-600">新建内容</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/config"
|
||||
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-gray-300 hover:border-[#C41E3A] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Settings className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-600">配置中心</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ConfigItem {
|
||||
id: string;
|
||||
key: string;
|
||||
value: Record<string, any>;
|
||||
category: 'feature' | 'style' | 'seo' | 'general';
|
||||
description: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
feature: '功能配置',
|
||||
style: '样式配置',
|
||||
seo: 'SEO 配置',
|
||||
general: '常规配置'
|
||||
};
|
||||
|
||||
const categoryColors = {
|
||||
feature: 'bg-blue-100 text-blue-800',
|
||||
style: 'bg-purple-100 text-purple-800',
|
||||
seo: 'bg-green-100 text-green-800',
|
||||
general: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [configs, setConfigs] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['feature', 'seo']));
|
||||
const [editedValues, setEditedValues] = useState<Record<string, Record<string, any>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setConfigs(data.configs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (configId: string) => {
|
||||
const editedValue = editedValues[configId];
|
||||
if (!editedValue) return;
|
||||
|
||||
try {
|
||||
setSaving(configId);
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: configId,
|
||||
value: editedValue
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setEditedValues(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[configId];
|
||||
return updated;
|
||||
});
|
||||
await fetchConfigs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const updated = new Set(prev);
|
||||
if (updated.has(category)) {
|
||||
updated.delete(category);
|
||||
} else {
|
||||
updated.add(category);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleValueChange = (configId: string, field: string, value: any) => {
|
||||
setEditedValues(prev => ({
|
||||
...prev,
|
||||
[configId]: {
|
||||
...prev[configId],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const getConfigValue = (config: ConfigItem, field: string) => {
|
||||
if (editedValues[config.id]?.[field] !== undefined) {
|
||||
return editedValues[config.id]![field];
|
||||
}
|
||||
return config.value[field];
|
||||
};
|
||||
|
||||
const hasChanges = (configId: string) => {
|
||||
return editedValues[configId] && Object.keys(editedValues[configId]).length > 0;
|
||||
};
|
||||
|
||||
const groupedConfigs = configs.reduce((acc, config) => {
|
||||
if (!acc[config.category]) {
|
||||
acc[config.category] = [];
|
||||
}
|
||||
acc[config.category]!.push(config);
|
||||
return acc;
|
||||
}, {} as Record<string, ConfigItem[]>);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">配置中心</h1>
|
||||
<p className="text-gray-600 mt-1">管理网站功能和样式配置</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchConfigs}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedConfigs).map(([category, categoryConfigs]) => (
|
||||
<div key={category} className="bg-white rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${categoryColors[category as keyof typeof categoryColors]}`}>
|
||||
{categoryLabels[category as keyof typeof categoryLabels]}
|
||||
</span>
|
||||
<span className="text-gray-600 text-sm">
|
||||
{categoryConfigs.length} 项配置
|
||||
</span>
|
||||
</div>
|
||||
{expandedCategories.has(category) ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div className="border-t divide-y">
|
||||
{categoryConfigs.map(config => (
|
||||
<div key={config.id} className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{config.key}</h3>
|
||||
{config.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{config.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{hasChanges(config.id) && (
|
||||
<button
|
||||
onClick={() => handleSave(config.id)}
|
||||
disabled={saving === config.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving === config.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{Object.entries(config.value).map(([field, value]) => {
|
||||
const currentValue = getConfigValue(config, field);
|
||||
|
||||
return (
|
||||
<div key={field} className="flex items-start gap-4">
|
||||
<label className="w-32 text-sm font-medium text-gray-700 pt-2">
|
||||
{field}
|
||||
</label>
|
||||
<div className="flex-1">
|
||||
{typeof value === 'boolean' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, e.target.checked)}
|
||||
className="w-4 h-4 text-[#C41E3A] border-gray-300 rounded focus:ring-[#C41E3A]"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentValue ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</label>
|
||||
) : typeof value === 'string' ? (
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
|
||||
/>
|
||||
) : typeof value === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
|
||||
/>
|
||||
) : Array.isArray(value) ? (
|
||||
<textarea
|
||||
value={Array.isArray(currentValue) ? currentValue.join('\n') : currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, e.target.value.split('\n').filter(Boolean))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent font-mono text-sm"
|
||||
placeholder="每行一个值"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={JSON.stringify(currentValue, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
handleValueChange(config.id, field, parsed);
|
||||
} catch (err) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users as UsersIcon,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'editor' | 'viewer';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
admin: '管理员',
|
||||
editor: '编辑',
|
||||
viewer: '查看者'
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
admin: 'bg-red-100 text-red-800',
|
||||
editor: 'bg-blue-100 text-blue-800',
|
||||
viewer: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
role: 'viewer' as 'admin' | 'editor' | 'viewer'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/users');
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.email || !formData.name || !formData.password || !formData.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
await fetchUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || '创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建用户失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('确定要删除此用户吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||
<p className="text-gray-600 mt-1">管理系统用户和权限</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border">
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
用户信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
角色
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredUsers.map(user => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[user.role]}`}>
|
||||
{roleLabels[user.role]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setFormData({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
password: '',
|
||||
role: user.role
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="text-[#C41E3A] hover:text-[#A01830] mr-4"
|
||||
>
|
||||
<Edit className="h-4 w-4 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
暂无用户数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">添加用户</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">角色</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
>
|
||||
<option value="viewer">查看者</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
|
||||
>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">编辑用户</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">新密码(留空则不修改)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">角色</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
>
|
||||
<option value="viewer">查看者</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedUser(null);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updateData: any = {
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
role: formData.role
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/admin/users/${selectedUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowEditModal(false);
|
||||
setSelectedUser(null);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新用户失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasPermission } from '@/lib/auth/permissions';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'config', 'read')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
const config = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(config[0]);
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (category) {
|
||||
conditions.push(eq(siteConfig.category, category as 'feature' | 'style' | 'seo' | 'general'));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const configs = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(whereClause)
|
||||
.orderBy(siteConfig.key);
|
||||
|
||||
const groupedConfigs = configs.reduce((acc, config) => {
|
||||
const cat = config.category;
|
||||
if (!acc[cat]) {
|
||||
acc[cat] = [];
|
||||
}
|
||||
acc[cat].push(config);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof configs>);
|
||||
|
||||
return NextResponse.json({
|
||||
configs: groupedConfigs,
|
||||
flat: configs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'config', 'update')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, value, category, description } = body;
|
||||
|
||||
if (!key || !value || !category) {
|
||||
return NextResponse.json({ error: '缺少必要字段' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(siteConfig)
|
||||
.set({
|
||||
value,
|
||||
description: description || existing[0]!.description,
|
||||
updatedAt: now,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.where(eq(siteConfig.key, key))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(updated[0]);
|
||||
}
|
||||
|
||||
const newConfig = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
key,
|
||||
value,
|
||||
category,
|
||||
description: description || null,
|
||||
updatedAt: now,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(newConfig[0], { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('创建/更新配置失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'config', 'update')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { configs } = body as { configs: Array<{ key: string; value: unknown; description?: string }> };
|
||||
|
||||
if (!Array.isArray(configs)) {
|
||||
return NextResponse.json({ error: '无效的数据格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results = [];
|
||||
|
||||
for (const config of configs) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(siteConfig)
|
||||
.set({
|
||||
value: config.value,
|
||||
description: config.description || existing[0]!.description,
|
||||
updatedAt: now,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.returning();
|
||||
results.push(updated[0]);
|
||||
} else {
|
||||
const created = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
category: 'general',
|
||||
description: config.description || null,
|
||||
updatedAt: now,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
results.push(created[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, updated: results.length });
|
||||
} catch (error) {
|
||||
console.error('批量更新配置失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content, contentVersions } from '@/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasPermission } from '@/lib/auth/permissions';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'read')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const item = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (item.length === 0) {
|
||||
return NextResponse.json({ error: '内容不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const versions = await db
|
||||
.select()
|
||||
.from(contentVersions)
|
||||
.where(eq(contentVersions.contentId, id))
|
||||
.orderBy(contentVersions.version);
|
||||
|
||||
return NextResponse.json({
|
||||
...item[0],
|
||||
versions,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取内容详情失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'update')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length === 0) {
|
||||
return NextResponse.json({ error: '内容不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const current = existingContent[0]!;
|
||||
const now = new Date();
|
||||
|
||||
const maxVersion = await db
|
||||
.select({ max: contentVersions.version })
|
||||
.from(contentVersions)
|
||||
.where(eq(contentVersions.contentId, id));
|
||||
|
||||
const nextVersion = (maxVersion[0]?.max || 0) + 1;
|
||||
|
||||
await db.insert(contentVersions).values({
|
||||
id: nanoid(),
|
||||
contentId: id,
|
||||
version: nextVersion,
|
||||
title: current.title,
|
||||
content: current.content,
|
||||
changes: {
|
||||
from: {
|
||||
title: current.title,
|
||||
content: current.content,
|
||||
excerpt: current.excerpt,
|
||||
status: current.status,
|
||||
},
|
||||
to: body,
|
||||
},
|
||||
changedBy: session.user.id,
|
||||
changedAt: now,
|
||||
});
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (body.title) updateData.title = body.title;
|
||||
if (body.slug) updateData.slug = body.slug;
|
||||
if (body.excerpt !== undefined) updateData.excerpt = body.excerpt;
|
||||
if (body.contentBody !== undefined) updateData.content = body.contentBody;
|
||||
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage;
|
||||
if (body.category !== undefined) updateData.category = body.category;
|
||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||
if (body.metadata !== undefined) updateData.metadata = body.metadata;
|
||||
|
||||
if (body.status) {
|
||||
updateData.status = body.status;
|
||||
if (body.status === 'published' && current.status !== 'published') {
|
||||
updateData.publishedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(content)
|
||||
.set(updateData)
|
||||
.where(eq(content.id, id))
|
||||
.returning();
|
||||
|
||||
await createAuditLog({
|
||||
userId: session.user.id,
|
||||
action: 'update',
|
||||
resourceType: 'content',
|
||||
resourceId: id,
|
||||
details: {
|
||||
changes: updateData,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated[0]);
|
||||
} catch (error) {
|
||||
console.error('更新内容失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'delete')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length === 0) {
|
||||
return NextResponse.json({ error: '内容不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.delete(contentVersions).where(eq(contentVersions.contentId, id));
|
||||
await db.delete(content).where(eq(content.id, id));
|
||||
|
||||
await createAuditLog({
|
||||
userId: session.user.id,
|
||||
action: 'delete',
|
||||
resourceType: 'content',
|
||||
resourceId: id,
|
||||
details: {
|
||||
title: existingContent[0]!.title,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('删除内容失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content } from '@/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasPermission } from '@/lib/auth/permissions';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { eq, desc, and, like, sql } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'read')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(content.type, type as 'news' | 'product' | 'service' | 'case'));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(content.status, status as 'draft' | 'published' | 'archived'));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(like(content.title, `%${search}%`));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(content.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(content)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取内容列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'create')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, title, slug, excerpt, contentBody, coverImage, category, tags, status: contentStatus, metadata } = body;
|
||||
|
||||
if (!type || !title || !slug) {
|
||||
return NextResponse.json({ error: '缺少必要字段' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length > 0) {
|
||||
return NextResponse.json({ error: 'Slug 已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const newContent = await db
|
||||
.insert(content)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
type,
|
||||
title,
|
||||
slug,
|
||||
excerpt: excerpt || null,
|
||||
content: contentBody || '',
|
||||
coverImage: coverImage || null,
|
||||
category: category || null,
|
||||
tags: tags || [],
|
||||
status: contentStatus || 'draft',
|
||||
publishedAt: contentStatus === 'published' ? now : null,
|
||||
authorId: session.user.id,
|
||||
metadata: metadata || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog({
|
||||
userId: session.user.id,
|
||||
action: 'create',
|
||||
resourceType: 'content',
|
||||
resourceId: newContent[0]!.id,
|
||||
details: {
|
||||
type,
|
||||
title,
|
||||
status: contentStatus || 'draft',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(newContent[0], { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('创建内容失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasPermission } from '@/lib/auth/permissions';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { uploadFile, deleteFile } from '@/lib/upload';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'create')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const type = (formData.get('type') as 'image' | 'document') || 'image';
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: '未找到文件' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await uploadFile(file, {
|
||||
type,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
userId: session.user.id,
|
||||
action: 'upload',
|
||||
resourceType: 'file',
|
||||
resourceId: result.id,
|
||||
details: {
|
||||
fileName: result.name,
|
||||
fileType: result.type,
|
||||
fileSize: result.size,
|
||||
url: result.url,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'content', 'delete')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileUrl = searchParams.get('url');
|
||||
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ error: '缺少文件 URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const success = await deleteFile(fileUrl);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: '文件不存在或删除失败' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('文件删除失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasPermission } from '@/lib/auth/permissions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'users', 'read')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (user.length === 0) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: user[0] });
|
||||
} catch (error) {
|
||||
console.error('获取用户详情失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'users', 'update')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { email, name, password, role } = body;
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (email) updateData.email = email;
|
||||
if (name) updateData.name = name;
|
||||
if (role) updateData.role = role;
|
||||
if (password) {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: updated[0]!.id,
|
||||
email: updated[0]!.email,
|
||||
name: updated[0]!.name,
|
||||
role: updated[0]!.role,
|
||||
createdAt: updated[0]!.createdAt,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新用户失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'users', 'delete')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (session.user.id === id) {
|
||||
return NextResponse.json({ error: '不能删除自己的账号' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { hasPermission } from '@/lib/auth/permissions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'users', 'read')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(users.createdAt);
|
||||
|
||||
return NextResponse.json({ users: allUsers });
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role as 'admin' | 'editor' | 'viewer';
|
||||
|
||||
if (!hasPermission(userRole, 'users', 'create')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, name, password, role } = body;
|
||||
|
||||
if (!email || !name || !password || !role) {
|
||||
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return NextResponse.json({ error: '邮箱已被使用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const newUser = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
email,
|
||||
name,
|
||||
passwordHash: hashedPassword,
|
||||
role,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: newUser[0]!.id,
|
||||
email: newUser[0]!.email,
|
||||
name: newUser[0]!.name,
|
||||
role: newUser[0]!.role,
|
||||
createdAt: newUser[0]!.createdAt,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建用户失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
Reference in New Issue
Block a user