+
+
+ 睿新致遠 CMS
+
+
+
+
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx
new file mode 100644
index 0000000..081f44d
--- /dev/null
+++ b/src/app/admin/login/page.tsx
@@ -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 (
+
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+ ← 返回首页
+
+
+
+
+
+ © {new Date().getFullYear()} 四川睿新致远科技有限公司 版权所有
+
+
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
new file mode 100644
index 0000000..63ad7f8
--- /dev/null
+++ b/src/app/admin/page.tsx
@@ -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
`count(*)` }).from(content),
+ db.select({ count: sql`count(*)` }).from(content).where(eq(content.status, 'published')),
+ db.select({ count: sql`count(*)` }).from(content).where(eq(content.status, 'draft')),
+ db.select({ count: sql`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 (
+
+
+
仪表盘
+
欢迎回来,{session?.user?.name}
+
+
+
+ {statCards.map((stat) => (
+
+
+
+
{stat.name}
+
{stat.value}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
最近内容
+
+ 查看全部
+
+
+
+ {stats.recentContent.length === 0 ? (
+
暂无内容
+ ) : (
+
+ {stats.recentContent.map((item) => (
+
+
+
+ {item.title}
+
+
+ {item.type} · {item.status === 'published' ? '已发布' : '草稿'}
+
+
+
+ 编辑
+
+
+ ))}
+
+ )}
+
+
+
+
+
快捷操作
+
+
+
+
+
+ 新建内容
+
+
+
+
+ 配置中心
+
+
+
+
+
+ );
+}
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
new file mode 100644
index 0000000..731eb2b
--- /dev/null
+++ b/src/app/admin/settings/page.tsx
@@ -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;
+ 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([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(null);
+ const [expandedCategories, setExpandedCategories] = useState>(new Set(['feature', 'seo']));
+ const [editedValues, setEditedValues] = useState>>({});
+
+ 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);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {Object.entries(groupedConfigs).map(([category, categoryConfigs]) => (
+
+
+
+ {expandedCategories.has(category) && (
+
+ {categoryConfigs.map(config => (
+
+
+
+
{config.key}
+ {config.description && (
+
{config.description}
+ )}
+
+ {hasChanges(config.id) && (
+
+ )}
+
+
+
+ {Object.entries(config.value).map(([field, value]) => {
+ const currentValue = getConfigValue(config, field);
+
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
new file mode 100644
index 0000000..19adae2
--- /dev/null
+++ b/src/app/admin/users/page.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [selectedUser, setSelectedUser] = useState(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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+ |
+ 用户信息
+ |
+
+ 角色
+ |
+
+ 创建时间
+ |
+
+ 操作
+ |
+
+
+
+ {filteredUsers.map(user => (
+
+
+
+
+
+
+
+ {user.name}
+ {user.email}
+
+
+ |
+
+
+ {roleLabels[user.role]}
+
+ |
+
+ {new Date(user.createdAt).toLocaleDateString('zh-CN')}
+ |
+
+
+
+ |
+
+ ))}
+
+
+
+
+ {filteredUsers.length === 0 && (
+
+ 暂无用户数据
+
+ )}
+
+
+ {/* Create Modal */}
+ {showCreateModal && (
+
+
+
添加用户
+
+
+
+ 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]"
+ />
+
+
+
+ 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]"
+ />
+
+
+
+ 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]"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Edit Modal */}
+ {showEditModal && selectedUser && (
+
+
+
编辑用户
+
+
+
+ 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]"
+ />
+
+
+
+ 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]"
+ />
+
+
+
+ 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]"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts
new file mode 100644
index 0000000..3caaa0c
--- /dev/null
+++ b/src/app/api/admin/config/route.ts
@@ -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);
+
+ 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 });
+ }
+}
diff --git a/src/app/api/admin/content/[id]/route.ts b/src/app/api/admin/content/[id]/route.ts
new file mode 100644
index 0000000..3b24278
--- /dev/null
+++ b/src/app/api/admin/content/[id]/route.ts
@@ -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 = {
+ 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 });
+ }
+}
diff --git a/src/app/api/admin/content/route.ts b/src/app/api/admin/content/route.ts
new file mode 100644
index 0000000..da01e1e
--- /dev/null
+++ b/src/app/api/admin/content/route.ts
@@ -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`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 });
+ }
+}
diff --git a/src/app/api/admin/upload/route.ts b/src/app/api/admin/upload/route.ts
new file mode 100644
index 0000000..4d434ce
--- /dev/null
+++ b/src/app/api/admin/upload/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
new file mode 100644
index 0000000..fa90f9f
--- /dev/null
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -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 = {
+ 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 });
+ }
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..60db2a4
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts
index 0a4c217..230079f 100644
--- a/src/app/api/auth/[...nextauth]/route.ts
+++ b/src/app/api/auth/[...nextauth]/route.ts
@@ -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;
diff --git a/src/components/admin/RichTextEditor.tsx b/src/components/admin/RichTextEditor.tsx
new file mode 100644
index 0000000..2a4e00c
--- /dev/null
+++ b/src/components/admin/RichTextEditor.tsx
@@ -0,0 +1,229 @@
+'use client';
+
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Image from '@tiptap/extension-image';
+import Link from '@tiptap/extension-link';
+import {
+ Bold,
+ Italic,
+ Strikethrough,
+ Code,
+ Heading1,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ Quote,
+ Undo,
+ Redo,
+ Link as LinkIcon,
+ Image as ImageIcon
+} from 'lucide-react';
+import { useCallback, useState } from 'react';
+
+interface RichTextEditorProps {
+ content: string;
+ onChange: (content: string) => void;
+}
+
+export default function RichTextEditor({ content, onChange }: RichTextEditorProps) {
+ const [uploading, setUploading] = useState(false);
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Image.configure({
+ HTMLAttributes: {
+ class: 'max-w-full h-auto rounded-lg',
+ },
+ }),
+ Link.configure({
+ openOnClick: false,
+ HTMLAttributes: {
+ class: 'text-[#C41E3A] underline',
+ },
+ }),
+ ],
+ content,
+ onUpdate: ({ editor }) => {
+ onChange(editor.getHTML());
+ },
+ });
+
+ const handleImageUpload = useCallback(async () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+
+ setUploading(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('type', 'image');
+
+ const res = await fetch('/api/admin/upload', {
+ method: 'POST',
+ body: formData,
+ });
+
+ const data = await res.json();
+ if (res.ok && editor) {
+ editor.chain().focus().setImage({ src: data.file.url }).run();
+ }
+ } catch (error) {
+ console.error('上传图片失败:', error);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ input.click();
+ }, [editor]);
+
+ const addLink = useCallback(() => {
+ if (!editor) return;
+
+ const url = window.prompt('输入链接地址:');
+ if (url) {
+ editor.chain().focus().setLink({ href: url }).run();
+ }
+ }, [editor]);
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 7c8ed15..3372a8f 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -55,7 +55,7 @@ export const siteConfig = sqliteTable('site_config', {
export const auditLogs = sqliteTable('audit_logs', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id),
- action: text('action', { enum: ['create', 'update', 'delete', 'publish', 'login'] }).notNull(),
+ action: text('action', { enum: ['create', 'update', 'delete', 'publish', 'login', 'logout', 'upload'] }).notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
details: text('details', { mode: 'json' }).$type>(),
diff --git a/src/db/seed.ts b/src/db/seed.ts
index a76448c..54e7c16 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -2,23 +2,33 @@ import { db } from './index';
import { users, siteConfig } from './schema';
import { nanoid } from 'nanoid';
import bcrypt from 'bcryptjs';
+import { eq } from 'drizzle-orm';
async function seed() {
console.log('🌱 开始种子数据...');
try {
- const hashedPassword = await bcrypt.hash('admin123456', 10);
- await db.insert(users).values({
- id: nanoid(),
- email: 'admin@novalon.cn',
- passwordHash: hashedPassword,
- name: '系统管理员',
- role: 'admin',
- createdAt: new Date(),
- updatedAt: new Date(),
- });
+ const existingAdmin = await db
+ .select()
+ .from(users)
+ .where(eq(users.email, 'admin@novalon.cn'))
+ .limit(1);
- console.log('✅ 创建管理员用户: admin@novalon.cn');
+ if (existingAdmin.length === 0) {
+ const hashedPassword = await bcrypt.hash('admin123456', 10);
+ await db.insert(users).values({
+ id: nanoid(),
+ email: 'admin@novalon.cn',
+ passwordHash: hashedPassword,
+ name: '系统管理员',
+ role: 'admin',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ console.log('✅ 创建管理员用户: admin@novalon.cn');
+ } else {
+ console.log('ℹ️ 管理员用户已存在,跳过创建');
+ }
const defaultConfigs = [
{
@@ -30,7 +40,7 @@ async function seed() {
categories: ['公司新闻', '产品发布', '合作动态', '行业资讯'],
sortOrder: 'desc',
},
- category: 'feature',
+ category: 'feature' as const,
description: '新闻模块配置',
updatedAt: new Date(),
},
@@ -42,7 +52,7 @@ async function seed() {
showPricing: true,
featuredProducts: ['erp', 'crm'],
},
- category: 'feature',
+ category: 'feature' as const,
description: '产品模块配置',
updatedAt: new Date(),
},
@@ -53,7 +63,7 @@ async function seed() {
enabled: true,
items: ['software', 'cloud', 'data', 'security'],
},
- category: 'feature',
+ category: 'feature' as const,
description: '服务模块配置',
updatedAt: new Date(),
},
@@ -65,15 +75,25 @@ async function seed() {
description: '以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者',
keywords: ['数字化转型', '软件开发', '云服务', '数据分析', '信息安全'],
},
- category: 'seo',
+ category: 'seo' as const,
description: '默认 SEO 配置',
updatedAt: new Date(),
},
];
for (const config of defaultConfigs) {
- await db.insert(siteConfig).values(config);
- console.log(`✅ 创建配置: ${config.key}`);
+ const existing = await db
+ .select()
+ .from(siteConfig)
+ .where(eq(siteConfig.key, config.key))
+ .limit(1);
+
+ if (existing.length === 0) {
+ await db.insert(siteConfig).values(config);
+ console.log(`✅ 创建配置: ${config.key}`);
+ } else {
+ console.log(`ℹ️ 配置已存在,跳过: ${config.key}`);
+ }
}
console.log('🎉 种子数据完成!');
@@ -81,8 +101,16 @@ async function seed() {
console.log('🔑 默认密码: admin123456');
} catch (error) {
console.error('❌ 种子数据失败:', error);
- process.exit(1);
+ throw error;
}
}
-seed();
+seed()
+ .then(() => {
+ console.log('✅ 种子数据脚本执行完成');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('❌ 种子数据脚本执行失败:', error);
+ process.exit(1);
+ });
diff --git a/src/lib/audit.ts b/src/lib/audit.ts
new file mode 100644
index 0000000..4c77ce8
--- /dev/null
+++ b/src/lib/audit.ts
@@ -0,0 +1,59 @@
+import { db } from '@/db';
+import { auditLogs } from '@/db/schema';
+import { nanoid } from 'nanoid';
+
+export type AuditAction = 'create' | 'update' | 'delete' | 'publish' | 'login' | 'logout' | 'upload';
+
+export interface AuditLogData {
+ userId?: string;
+ action: AuditAction;
+ resourceType: string;
+ resourceId?: string;
+ details?: Record;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+export async function createAuditLog(data: AuditLogData) {
+ try {
+ await db.insert(auditLogs).values({
+ id: nanoid(),
+ userId: data.userId || null,
+ action: data.action,
+ resourceType: data.resourceType,
+ resourceId: data.resourceId || null,
+ details: data.details || null,
+ ipAddress: data.ipAddress || null,
+ userAgent: data.userAgent || null,
+ timestamp: new Date(),
+ });
+ } catch (error) {
+ console.error('创建审计日志失败:', error);
+ }
+}
+
+export function getActionLabel(action: AuditAction): string {
+ const labels: Record = {
+ create: '创建',
+ update: '更新',
+ delete: '删除',
+ publish: '发布',
+ login: '登录',
+ logout: '登出',
+ upload: '上传',
+ };
+ return labels[action];
+}
+
+export function getActionColor(action: AuditAction): string {
+ const colors: Record = {
+ create: 'bg-green-100 text-green-800',
+ update: 'bg-blue-100 text-blue-800',
+ delete: 'bg-red-100 text-red-800',
+ publish: 'bg-purple-100 text-purple-800',
+ login: 'bg-cyan-100 text-cyan-800',
+ logout: 'bg-gray-100 text-gray-800',
+ upload: 'bg-yellow-100 text-yellow-800',
+ };
+ return colors[action];
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 1a9b72f..ace7532 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,15 +1,11 @@
-import { NextAuthOptions } from 'next-auth';
+import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
-import EmailProvider from 'next-auth/providers/email';
-import { Resend } from 'resend';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
-const resend = new Resend(process.env.RESEND_API_KEY);
-
-export const authOptions: NextAuthOptions = {
+export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
CredentialsProvider({
name: '邮箱密码',
@@ -22,19 +18,20 @@ export const authOptions: NextAuthOptions = {
return null;
}
- const user = await db
+ const userResult = await db
.select()
.from(users)
- .where(eq(users.email, credentials.email))
+ .where(eq(users.email, credentials.email as string))
.limit(1);
- if (user.length === 0) {
+ const user = userResult[0];
+ if (!user) {
return null;
}
const isValid = await bcrypt.compare(
- credentials.password,
- user[0].passwordHash || ''
+ credentials.password as string,
+ user.passwordHash || ''
);
if (!isValid) {
@@ -42,43 +39,13 @@ export const authOptions: NextAuthOptions = {
}
return {
- id: user[0].id,
- email: user[0].email,
- name: user[0].name,
- role: user[0].role,
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ role: user.role,
};
},
}),
- EmailProvider({
- server: {},
- from: process.env.EMAIL_FROM || 'noreply@novalon.cn',
- sendVerificationRequest: async ({ identifier: email, url }) => {
- try {
- await resend.emails.send({
- from: process.env.EMAIL_FROM || 'noreply@novalon.cn',
- to: email,
- subject: '睿新致遠 - 登录验证链接',
- html: `
-
-
睿新致遠管理后台登录
-
您好!
-
您收到这封邮件是因为您请求登录睿新致遠管理后台。
-
请点击下方按钮完成登录:
-
- 立即登录
-
-
如果您没有请求此链接,请忽略此邮件。
-
-
四川睿新致远科技有限公司
-
- `,
- });
- } catch (error) {
- console.error('发送邮件失败:', error);
- throw new Error('发送邮件失败');
- }
- },
- }),
],
callbacks: {
async jwt({ token, user }) {
@@ -103,4 +70,4 @@ export const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
},
-};
+});
diff --git a/src/lib/auth/check-permission.ts b/src/lib/auth/check-permission.ts
index 344e5ca..19ebd24 100644
--- a/src/lib/auth/check-permission.ts
+++ b/src/lib/auth/check-permission.ts
@@ -1,12 +1,11 @@
-import { getServerSession } from 'next-auth';
-import { authOptions } from '../auth';
+import { auth } from '../auth';
import { hasPermission, Role, Resource, Action } from './permissions';
export async function checkPermission(
resource: Resource,
action: Action
): Promise<{ allowed: boolean; userId?: string; role?: Role }> {
- const session = await getServerSession(authOptions);
+ const session = await auth();
if (!session || !session.user) {
return { allowed: false };
diff --git a/src/lib/color-contrast.ts b/src/lib/color-contrast.ts
index bd27419..6fbf469 100644
--- a/src/lib/color-contrast.ts
+++ b/src/lib/color-contrast.ts
@@ -20,11 +20,11 @@ function hexToRgb(hex: string): ColorRGB {
function getLuminance(rgb: ColorRGB): number {
const { r, g, b } = rgb;
- const a = [r, g, b].map(v => {
- v /= 255;
- return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ const values = [r, g, b].map(v => {
+ const normalized = v / 255;
+ return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4);
});
- return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
+ return values[0]! * 0.2126 + values[1]! * 0.7152 + values[2]! * 0.0722;
}
export function calculateContrastRatio(foreground: string, background: string): number {
@@ -43,21 +43,18 @@ export function calculateContrastRatio(foreground: string, background: string):
export function meetsWCAGStandard(
foreground: string,
background: string,
- level: 'AA' | 'AAA',
- textSize: 'normal' | 'large'
+ level: 'AA' | 'AAA' = 'AA',
+ textSize: 'normal' | 'large' = 'normal'
): ContrastResult {
const ratio = calculateContrastRatio(foreground, background);
- let requiredRatio: number;
- if (level === 'AA') {
- requiredRatio = textSize === 'normal' ? 4.5 : 3;
- } else {
- requiredRatio = textSize === 'normal' ? 7 : 4.5;
- }
+ const requiredRatio = level === 'AAA'
+ ? (textSize === 'large' ? 4.5 : 7)
+ : (textSize === 'large' ? 3 : 4.5);
return {
passes: ratio >= requiredRatio,
ratio,
- requiredRatio
+ requiredRatio,
};
}
diff --git a/src/lib/upload.ts b/src/lib/upload.ts
new file mode 100644
index 0000000..e589f6e
--- /dev/null
+++ b/src/lib/upload.ts
@@ -0,0 +1,190 @@
+import { writeFile, mkdir, stat } from 'fs/promises';
+import { existsSync } from 'fs';
+import path from 'path';
+import { nanoid } from 'nanoid';
+
+export interface UploadOptions {
+ maxSize?: number;
+ allowedTypes?: string[];
+ type: 'image' | 'document';
+ userId?: string;
+}
+
+export interface UploadResult {
+ id: string;
+ name: string;
+ size: number;
+ type: string;
+ url: string;
+ path: string;
+ uploadedAt: Date;
+ uploadedBy?: string;
+}
+
+const FILE_SIGNATURES: Record = {
+ 'image/jpeg': Buffer.from([0xFF, 0xD8, 0xFF]),
+ 'image/png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
+ 'image/gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
+ 'image/webp': Buffer.from([0x52, 0x49, 0x46, 0x46]),
+ 'application/pdf': Buffer.from([0x25, 0x50, 0x44, 0x46]),
+};
+
+const ALLOWED_TYPES: Record = {
+ image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
+ document: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
+};
+
+const MAX_FILE_SIZES: Record = {
+ image: 5 * 1024 * 1024, // 5MB
+ document: 10 * 1024 * 1024, // 10MB
+};
+
+const DANGEROUS_EXTENSIONS = ['.exe', '.bat', '.cmd', '.sh', '.php', '.jsp', '.asp', '.aspx', '.js'];
+
+export function getFileExtension(mimeType: string): string {
+ const extensions: Record = {
+ 'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif',
+ 'image/webp': '.webp',
+ 'image/svg+xml': '.svg',
+ 'application/pdf': '.pdf',
+ 'application/msword': '.doc',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
+ };
+ return extensions[mimeType] || '';
+}
+
+export function isAllowedType(mimeType: string, type: 'image' | 'document'): boolean {
+ return ALLOWED_TYPES[type]?.includes(mimeType) || false;
+}
+
+export function validateFileSignature(buffer: Buffer, mimeType: string): boolean {
+ if (mimeType === 'image/svg+xml') {
+ return true;
+ }
+
+ const signature = FILE_SIGNATURES[mimeType];
+ if (!signature) {
+ return true;
+ }
+
+ for (let i = 0; i < signature.length; i++) {
+ if (buffer[i] !== signature[i]) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+export function sanitizeFileName(fileName: string): string {
+ return fileName
+ .replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]/g, '_')
+ .replace(/\.{2,}/g, '.')
+ .toLowerCase();
+}
+
+export function isDangerousFile(fileName: string): boolean {
+ const ext = path.extname(fileName).toLowerCase();
+ return DANGEROUS_EXTENSIONS.includes(ext);
+}
+
+export function getDatePath(): string {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ const day = String(now.getDate()).padStart(2, '0');
+ return `${year}/${month}/${day}`;
+}
+
+export async function uploadFile(
+ file: File,
+ options: UploadOptions
+): Promise {
+ const {
+ type = 'image',
+ maxSize = MAX_FILE_SIZES[type] || 5 * 1024 * 1024,
+ allowedTypes = ALLOWED_TYPES[type] || [],
+ userId
+ } = options;
+
+ if (file.size > maxSize) {
+ throw new Error(`文件大小超过限制 (最大 ${Math.round(maxSize / 1024 / 1024)}MB)`);
+ }
+
+ if (!allowedTypes.includes(file.type)) {
+ throw new Error(`不支持的文件类型: ${file.type}`);
+ }
+
+ if (isDangerousFile(file.name)) {
+ throw new Error('不允许上传此类型的文件');
+ }
+
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ if (!validateFileSignature(buffer, file.type)) {
+ throw new Error('文件内容与声明类型不匹配');
+ }
+
+ const uploadBaseDir = process.env.UPLOAD_DIR || './uploads';
+ const datePath = getDatePath();
+ const uploadDir = path.join(process.cwd(), uploadBaseDir, type, datePath);
+
+ if (!existsSync(uploadDir)) {
+ await mkdir(uploadDir, { recursive: true });
+ }
+
+ const fileId = nanoid();
+ const extension = getFileExtension(file.type);
+ const sanitizedOriginalName = sanitizeFileName(file.name);
+ const fileName = `${fileId}${extension}`;
+ const filePath = path.join(uploadDir, fileName);
+
+ await writeFile(filePath, buffer);
+
+ const publicUrl = `/uploads/${type}/${datePath}/${fileName}`;
+
+ return {
+ id: fileId,
+ name: sanitizedOriginalName,
+ size: file.size,
+ type: file.type,
+ url: publicUrl,
+ path: filePath,
+ uploadedAt: new Date(),
+ uploadedBy: userId,
+ };
+}
+
+export async function deleteFile(fileUrl: string): Promise {
+ try {
+ const filePath = path.join(process.cwd(), 'public', fileUrl);
+
+ if (!existsSync(filePath)) {
+ return false;
+ }
+
+ const { unlink } = await import('fs/promises');
+ await unlink(filePath);
+
+ return true;
+ } catch (error) {
+ console.error('删除文件失败:', error);
+ return false;
+ }
+}
+
+export async function getFileInfo(filePath: string) {
+ try {
+ const stats = await stat(filePath);
+ return {
+ size: stats.size,
+ createdAt: stats.birthtime,
+ modifiedAt: stats.mtime,
+ };
+ } catch (error) {
+ return null;
+ }
+}
diff --git a/test-framework/playwright.config.ts b/test-framework/playwright.config.ts
index a8a479b..1ff0be4 100644
--- a/test-framework/playwright.config.ts
+++ b/test-framework/playwright.config.ts
@@ -1,6 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { getEnvironmentConfig } from './shared/config/environments';
-import { CustomReporter } from './shared/utils/reporting/CustomReporter';
const config = defineConfig({
testDir: './dev-audit',
diff --git a/test-framework/shared/config/environments.ts b/test-framework/shared/config/environments.ts
index e73924a..9b2835c 100644
--- a/test-framework/shared/config/environments.ts
+++ b/test-framework/shared/config/environments.ts
@@ -25,5 +25,5 @@ export const environments: Record = {
};
export function getEnvironmentConfig(env: string = 'development'): TestConfig {
- return environments[env] || environments.development;
+ return environments[env] ?? environments.development!;
}
diff --git a/test-framework/shared/config/index.ts b/test-framework/shared/config/index.ts
new file mode 100644
index 0000000..03df0a1
--- /dev/null
+++ b/test-framework/shared/config/index.ts
@@ -0,0 +1,4 @@
+export * from './environments';
+export * from './test-pages';
+export * from './test-data';
+export * from './base.config';
diff --git a/test-framework/shared/config/test-pages.ts b/test-framework/shared/config/test-pages.ts
index 556bdda..505c74a 100644
--- a/test-framework/shared/config/test-pages.ts
+++ b/test-framework/shared/config/test-pages.ts
@@ -66,7 +66,7 @@ export const testPages: Record = {
};
export function getPageConfig(pageKey: string): PageConfig {
- return testPages[pageKey] || testPages.home;
+ return testPages[pageKey] ?? testPages.home!;
}
export function getAllPageConfigs(): PageConfig[] {
diff --git a/test-framework/shared/fixtures/base.fixture.ts b/test-framework/shared/fixtures/base.fixture.ts
index 7ca5fe4..c55bd7e 100644
--- a/test-framework/shared/fixtures/base.fixture.ts
+++ b/test-framework/shared/fixtures/base.fixture.ts
@@ -1,6 +1,7 @@
import { test as base } from '@playwright/test';
import { BasePage, HomePage, AboutPage, ContactPage, ProductsPage, ServicesPage, CasesPage, NewsPage } from '../pages';
import { getEnvironmentConfig } from '../config/environments';
+import { TestConfig as CustomTestConfig } from '../types';
type MyFixtures = {
basePage: BasePage;
@@ -11,52 +12,52 @@ type MyFixtures = {
servicesPage: ServicesPage;
casesPage: CasesPage;
newsPage: NewsPage;
- config: any;
+ config: CustomTestConfig;
};
export const test = base.extend({
- config: async ({}, use) => {
+ config: async ({}, use: (value: CustomTestConfig) => Promise) => {
const env = process.env.TEST_ENV || 'development';
const config = getEnvironmentConfig(env);
await use(config);
},
- basePage: async ({ page }, use) => {
+ basePage: async ({ page }, use: (value: BasePage) => Promise) => {
const basePage = new BasePage(page, '/');
await use(basePage);
},
- homePage: async ({ page, config }, use) => {
+ homePage: async ({ page, config }, use: (value: HomePage) => Promise) => {
const homePage = new HomePage(page, config);
await use(homePage);
},
- aboutPage: async ({ page, config }, use) => {
+ aboutPage: async ({ page, config }, use: (value: AboutPage) => Promise) => {
const aboutPage = new AboutPage(page, config);
await use(aboutPage);
},
- contactPage: async ({ page, config }, use) => {
+ contactPage: async ({ page, config }, use: (value: ContactPage) => Promise) => {
const contactPage = new ContactPage(page, config);
await use(contactPage);
},
- productsPage: async ({ page, config }, use) => {
+ productsPage: async ({ page, config }, use: (value: ProductsPage) => Promise) => {
const productsPage = new ProductsPage(page, config);
await use(productsPage);
},
- servicesPage: async ({ page, config }, use) => {
+ servicesPage: async ({ page, config }, use: (value: ServicesPage) => Promise) => {
const servicesPage = new ServicesPage(page, config);
await use(servicesPage);
},
- casesPage: async ({ page, config }, use) => {
+ casesPage: async ({ page, config }, use: (value: CasesPage) => Promise) => {
const casesPage = new CasesPage(page, config);
await use(casesPage);
},
- newsPage: async ({ page, config }, use) => {
+ newsPage: async ({ page, config }, use: (value: NewsPage) => Promise) => {
const newsPage = new NewsPage(page, config);
await use(newsPage);
}
diff --git a/test-framework/shared/fixtures/index.ts b/test-framework/shared/fixtures/index.ts
new file mode 100644
index 0000000..6b88fbb
--- /dev/null
+++ b/test-framework/shared/fixtures/index.ts
@@ -0,0 +1 @@
+export * from './base.fixture';
diff --git a/test-framework/shared/index.ts b/test-framework/shared/index.ts
index 3a2db27..43de22a 100644
--- a/test-framework/shared/index.ts
+++ b/test-framework/shared/index.ts
@@ -2,8 +2,3 @@ export * from './config';
export * from './pages';
export * from './types';
export * from './fixtures';
-export * from './utils/performance/PerformanceMonitor';
-export * from './utils/performance/LighthouseRunner';
-export * from './utils/performance/CoreWebVitals';
-export * from './utils/accessibility/AccessibilityTester';
-export * from './utils/seo/SEOValidator';
diff --git a/test-framework/shared/pages/AboutPage.ts b/test-framework/shared/pages/AboutPage.ts
index 5292a89..2756073 100644
--- a/test-framework/shared/pages/AboutPage.ts
+++ b/test-framework/shared/pages/AboutPage.ts
@@ -1,9 +1,10 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class AboutPage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('about');
super(page, pageConfig.url, config);
}
diff --git a/test-framework/shared/pages/CasesPage.ts b/test-framework/shared/pages/CasesPage.ts
index 4dda6bc..fda46b9 100644
--- a/test-framework/shared/pages/CasesPage.ts
+++ b/test-framework/shared/pages/CasesPage.ts
@@ -1,9 +1,10 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class CasesPage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('cases');
super(page, pageConfig.url, config);
}
diff --git a/test-framework/shared/pages/ContactPage.ts b/test-framework/shared/pages/ContactPage.ts
index dafcf0d..2dc2d36 100644
--- a/test-framework/shared/pages/ContactPage.ts
+++ b/test-framework/shared/pages/ContactPage.ts
@@ -1,9 +1,10 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class ContactPage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('contact');
super(page, pageConfig.url, config);
}
diff --git a/test-framework/shared/pages/HomePage.ts b/test-framework/shared/pages/HomePage.ts
index b54f71d..0024c12 100644
--- a/test-framework/shared/pages/HomePage.ts
+++ b/test-framework/shared/pages/HomePage.ts
@@ -1,16 +1,14 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class HomePage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('home');
super(page, pageConfig.url, config);
- this.pageConfig = pageConfig;
}
- private pageConfig;
-
async getHeroTitle(): Promise {
return await this.getText('h1');
}
diff --git a/test-framework/shared/pages/NewsPage.ts b/test-framework/shared/pages/NewsPage.ts
index c11d812..f18e209 100644
--- a/test-framework/shared/pages/NewsPage.ts
+++ b/test-framework/shared/pages/NewsPage.ts
@@ -1,9 +1,10 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class NewsPage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('news');
super(page, pageConfig.url, config);
}
diff --git a/test-framework/shared/pages/ProductsPage.ts b/test-framework/shared/pages/ProductsPage.ts
index 0cbbf33..b9d6b43 100644
--- a/test-framework/shared/pages/ProductsPage.ts
+++ b/test-framework/shared/pages/ProductsPage.ts
@@ -1,9 +1,10 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class ProductsPage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('products');
super(page, pageConfig.url, config);
}
diff --git a/test-framework/shared/pages/ServicesPage.ts b/test-framework/shared/pages/ServicesPage.ts
index c0978ea..09482bd 100644
--- a/test-framework/shared/pages/ServicesPage.ts
+++ b/test-framework/shared/pages/ServicesPage.ts
@@ -1,9 +1,10 @@
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { getPageConfig } from '../config/test-pages';
+import { TestConfig } from '../types';
export class ServicesPage extends BasePage {
- constructor(page: Page, config?) {
+ constructor(page: Page, config?: TestConfig) {
const pageConfig = getPageConfig('services');
super(page, pageConfig.url, config);
}
diff --git a/test-framework/shared/types/page.types.ts b/test-framework/shared/types/page.types.ts
index f7e17cd..752e778 100644
--- a/test-framework/shared/types/page.types.ts
+++ b/test-framework/shared/types/page.types.ts
@@ -1,5 +1,3 @@
-import { Page, Locator } from '@playwright/test';
-
export interface PageConfig {
name: string;
url: string;
diff --git a/test-framework/shared/types/reporting.ts b/test-framework/shared/types/reporting.ts
index c045fd5..4aea4fe 100644
--- a/test-framework/shared/types/reporting.ts
+++ b/test-framework/shared/types/reporting.ts
@@ -28,6 +28,12 @@ export interface PerformanceMetrics {
firstInputDelay: number;
}
+export interface PerformanceBaseline {
+ timestamp: number;
+ metrics: PerformanceMetrics;
+ url: string;
+}
+
export interface ComparisonResult {
status: 'regression' | 'improvement' | 'stable' | 'no-baseline';
difference: number;
diff --git a/test-framework/shared/utils/performance/CoreWebVitals.ts b/test-framework/shared/utils/performance/CoreWebVitals.ts
index cb1a85d..2eaf210 100644
--- a/test-framework/shared/utils/performance/CoreWebVitals.ts
+++ b/test-framework/shared/utils/performance/CoreWebVitals.ts
@@ -1,16 +1,20 @@
import { Page } from '@playwright/test';
-import { CoreWebVitals } from '../../types';
+import { CoreWebVitals as CoreWebVitalsMetrics } from '../../types';
export class CoreWebVitals {
constructor(private page: Page) {}
async measureLCP(): Promise {
return await this.page.evaluate(() => {
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
- resolve(lastEntry.startTime);
+ if (lastEntry) {
+ resolve(lastEntry.startTime);
+ } else {
+ resolve(0);
+ }
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
});
@@ -18,11 +22,15 @@ export class CoreWebVitals {
async measureFID(): Promise {
return await this.page.evaluate(() => {
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
- const firstEntry = entries[0];
- resolve(firstEntry.processingStart - firstEntry.startTime);
+ const firstEntry = entries[0] as any;
+ if (firstEntry) {
+ resolve(firstEntry.processingStart - firstEntry.startTime);
+ } else {
+ resolve(0);
+ }
}).observe({ type: 'first-input', buffered: true });
});
});
@@ -30,12 +38,12 @@ export class CoreWebVitals {
async measureCLS(): Promise {
return await this.page.evaluate(() => {
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
- if (!entry.hadRecentInput) {
- const value = entry.value;
+ if (!(entry as any).hadRecentInput) {
+ const value = (entry as any).value;
clsValue = Math.max(clsValue, value);
}
}
@@ -46,7 +54,7 @@ export class CoreWebVitals {
});
}
- async measureAll(): Promise {
+ async measureAll(): Promise {
const [lcp, fid, cls] = await Promise.all([
this.measureLCP(),
this.measureFID(),
@@ -69,12 +77,14 @@ export class CoreWebVitals {
async measureFCP(): Promise {
return await this.page.evaluate(() => {
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const fcpEntry = entries.find((entry: any) => entry.name === 'first-contentful-paint');
if (fcpEntry) {
resolve(fcpEntry.startTime);
+ } else {
+ resolve(0);
}
}).observe({ type: 'paint', buffered: true });
});
diff --git a/test-framework/shared/utils/performance/LighthouseRunner.ts b/test-framework/shared/utils/performance/LighthouseRunner.ts
index 1ea25aa..89e6089 100644
--- a/test-framework/shared/utils/performance/LighthouseRunner.ts
+++ b/test-framework/shared/utils/performance/LighthouseRunner.ts
@@ -6,7 +6,7 @@ export class LighthouseRunner {
async runLighthouse(url: string): Promise {
const results = await this.page.evaluate(async () => {
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
if (!(window as any).lighthouse) {
resolve({
performance: 0,
@@ -46,7 +46,16 @@ export class LighthouseRunner {
};
}> {
const results = await this.page.evaluate(async () => {
- return new Promise((resolve) => {
+ return new Promise<{
+ score: number;
+ metrics: {
+ firstContentfulPaint: number;
+ largestContentfulPaint: number;
+ cumulativeLayoutShift: number;
+ firstInputDelay: number;
+ speedIndex: number;
+ };
+ }>((resolve) => {
if (!(window as any).lighthouse) {
resolve({
score: 0,
@@ -61,18 +70,17 @@ export class LighthouseRunner {
return;
}
- (window as any).lighthouse(location.href, {
+ (window as any).lighthouse(window.location.href, {
onlyCategories: ['performance']
}).then((result: any) => {
- const audits = result.audits;
resolve({
score: Math.round(result.categories.performance.score * 100),
metrics: {
- firstContentfulPaint: audits['first-contentful-paint'].numericValue,
- largestContentfulPaint: audits['largest-contentful-paint'].numericValue,
- cumulativeLayoutShift: audits['cumulative-layout-shift'].numericValue,
- firstInputDelay: audits['max-potential-fid'].numericValue,
- speedIndex: audits['speed-index'].numericValue
+ firstContentfulPaint: result.audits['first-contentful-paint'].numericValue,
+ largestContentfulPaint: result.audits['largest-contentful-paint'].numericValue,
+ cumulativeLayoutShift: result.audits['cumulative-layout-shift'].numericValue,
+ firstInputDelay: result.audits['max-potential-fid'].numericValue,
+ speedIndex: result.audits['speed-index'].numericValue
}
});
});
diff --git a/test-framework/shared/utils/reporting/CustomReporter.ts b/test-framework/shared/utils/reporting/CustomReporter.ts
index 8123874..b6208a1 100644
--- a/test-framework/shared/utils/reporting/CustomReporter.ts
+++ b/test-framework/shared/utils/reporting/CustomReporter.ts
@@ -6,7 +6,7 @@ export class CustomReporter {
private results: any[] = [];
private startTime: number = Date.now();
- onBegin(config: any, suite: Suite) {
+ onBegin(_config: any, suite: Suite) {
console.log('\n=== 测试执行开始 ===');
console.log(`测试套件: ${suite.allTests().length} 个测试`);
}
@@ -38,7 +38,7 @@ export class CustomReporter {
return 'form';
}
- private generateCustomReport(result: FullResult, duration: number): string {
+ private generateCustomReport(_result: FullResult, duration: number): string {
const passed = this.results.filter(r => r.status === 'passed').length;
const failed = this.results.filter(r => r.status === 'failed').length;
const passRate = ((passed / this.results.length) * 100).toFixed(2);
diff --git a/test-framework/shared/utils/reporting/PerformanceBaseline.ts b/test-framework/shared/utils/reporting/PerformanceBaseline.ts
index 480749e..496812e 100644
--- a/test-framework/shared/utils/reporting/PerformanceBaseline.ts
+++ b/test-framework/shared/utils/reporting/PerformanceBaseline.ts
@@ -1,15 +1,28 @@
-import { TestResult, PerformanceMetrics, ComparisonResult } from '../../types/reporting';
+import { TestResult, PerformanceMetrics, ComparisonResult, PerformanceBaseline as PerformanceBaselineType } from '../../types/reporting';
export class PerformanceBaseline {
private baseline: Map = new Map();
- calculate(results: TestResult[]): PerformanceBaseline {
+ calculate(results: TestResult[]): PerformanceBaselineType {
results.forEach(result => {
if (result.type === 'performance' && result.metrics) {
this.updateBaseline(result);
}
});
- return this;
+
+ const firstBaseline = this.baseline.values().next().value;
+ return {
+ timestamp: Date.now(),
+ metrics: firstBaseline || {
+ loadTime: 0,
+ domContentLoaded: 0,
+ firstContentfulPaint: 0,
+ largestContentfulPaint: 0,
+ cumulativeLayoutShift: 0,
+ firstInputDelay: 0
+ },
+ url: ''
+ };
}
private updateBaseline(result: TestResult): void {
diff --git a/test-framework/shared/utils/seo/SEOValidator.ts b/test-framework/shared/utils/seo/SEOValidator.ts
index ae6e6d4..a341f2d 100644
--- a/test-framework/shared/utils/seo/SEOValidator.ts
+++ b/test-framework/shared/utils/seo/SEOValidator.ts
@@ -113,7 +113,7 @@ export class SEOValidator {
};
}
- private calculateScore(metaTags: MetaTagResult, headings: HeadingResult, links: LinkResult, images: ImageResult): number {
+ private calculateScore(metaTags: MetaTagResult, headings: HeadingResult, _links: LinkResult, images: ImageResult): number {
let score = 0;
let total = 0;
diff --git a/test-framework/shared/utils/testing/TestDataFactory.ts b/test-framework/shared/utils/testing/TestDataFactory.ts
index 2cc1f1c..4ce4cbc 100644
--- a/test-framework/shared/utils/testing/TestDataFactory.ts
+++ b/test-framework/shared/utils/testing/TestDataFactory.ts
@@ -1,4 +1,4 @@
-import { formData, performanceThresholds } from '../../config/test-data';
+import { performanceThresholds } from '../../config/test-data';
export interface ContactFormData {
name: string;
diff --git a/test-framework/shared/utils/testing/TestDataVersion.ts b/test-framework/shared/utils/testing/TestDataVersion.ts
index aad8292..51d73ec 100644
--- a/test-framework/shared/utils/testing/TestDataVersion.ts
+++ b/test-framework/shared/utils/testing/TestDataVersion.ts
@@ -19,7 +19,7 @@ export class TestDataVersion {
}
listVersions(): string[] {
- return Array.from(new Set(Array.from(this.versions.keys()).map(k => k.split('-')[0])));
+ return Array.from(new Set(Array.from(this.versions.keys()).map(k => k.split('-')[0]).filter((v): v is string => v !== undefined)));
}
export(): string {