feat: 重构用户角色系统为管理员标识

- 将用户角色字段从role改为is_admin布尔值
- 更新相关API权限检查逻辑
- 修改数据库schema和迁移文件
- 调整前端用户显示逻辑
- 添加API响应工具函数
- 优化权限检查中间件
- 重构英雄组件为原子组件
This commit is contained in:
张翔
2026-03-12 20:45:43 +08:00
parent b207bfa7af
commit f357330ba8
22 changed files with 1078 additions and 552 deletions
+17
View File
@@ -0,0 +1,17 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text,
`name` text NOT NULL,
`is_admin` integer DEFAULT false NOT NULL,
`avatar` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "password_hash", "name", "is_admin", "avatar", "created_at", "updated_at") SELECT "id", "email", "password_hash", "name", "is_admin", "avatar", "created_at", "updated_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
+502
View File
@@ -0,0 +1,502 @@
{
"version": "6",
"dialect": "sqlite",
"id": "923c66d2-b19b-4d84-b88c-dc75d07fefd6",
"prevId": "98ef90e0-460c-4b25-9197-bf2f4900d3f9",
"tables": {
"audit_logs": {
"name": "audit_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"audit_logs_user_id_users_id_fk": {
"name": "audit_logs_user_id_users_id_fk",
"tableFrom": "audit_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content": {
"name": "content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_image": {
"name": "cover_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"content_slug_unique": {
"name": "content_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"content_author_id_users_id_fk": {
"name": "content_author_id_users_id_fk",
"tableFrom": "content",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_versions": {
"name": "content_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"changes": {
"name": "changes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"changed_at": {
"name": "changed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"content_versions_content_id_content_id_fk": {
"name": "content_versions_content_id_content_id_fk",
"tableFrom": "content_versions",
"tableTo": "content",
"columnsFrom": [
"content_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"content_versions_changed_by_users_id_fk": {
"name": "content_versions_changed_by_users_id_fk",
"tableFrom": "content_versions",
"tableTo": "users",
"columnsFrom": [
"changed_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_by": {
"name": "updated_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"site_config_key_unique": {
"name": "site_config_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"site_config_updated_by_users_id_fk": {
"name": "site_config_updated_by_users_id_fk",
"tableFrom": "site_config",
"tableTo": "users",
"columnsFrom": [
"updated_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"users\".\"role\"": "\"users\".\"is_admin\""
}
},
"internal": {
"indexes": {}
}
}
+8 -1
View File
@@ -8,6 +8,13 @@
"when": 1772974841798,
"tag": "0000_white_justice",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773202935722,
"tag": "0001_clammy_toro",
"breakpoints": true
}
]
}
}
+2
View File
@@ -1,3 +1,5 @@
import '@testing-library/jest-dom';
jest.mock('next-auth', () => {
return {
__esModule: true,
+1 -2
View File
@@ -129,8 +129,7 @@ export default function AdminLayout({
{session?.user?.name}
</p>
<p className="text-xs text-gray-500 truncate">
{session?.user?.role === 'admin' ? '管理员' :
session?.user?.role === 'editor' ? '编辑' : '查看者'}
{session?.user?.isAdmin ? '管理员' : '用户'}
</p>
</div>
<button
+64 -48
View File
@@ -1,23 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { auth } from '@/lib/auth';
import { hasPermission } from '@/lib/auth/permissions';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { forbidden, success, notFound, validationError, badRequest, handleApiError } from '@/lib/api-response';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export async function GET(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const { searchParams } = new URL(request.url);
@@ -32,10 +26,10 @@ export async function GET(request: NextRequest) {
.limit(1);
if (config.length === 0) {
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
return notFound('配置不存在');
}
return NextResponse.json(config[0]);
return success(config[0]);
}
const conditions = [];
@@ -61,35 +55,29 @@ export async function GET(request: NextRequest) {
return acc;
}, {} as Record<string, typeof configs>);
return NextResponse.json({
return success({
configs: groupedConfigs,
flat: configs,
});
} catch (error) {
console.error('获取配置失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
const body = await request.json();
const { key, value, category, description } = body;
if (!key || !value || !category) {
return NextResponse.json({ error: '缺少必要字段' }, { status: 400 });
return validationError('缺少必要字段', { required: ['key', 'value', 'category'] });
}
const existing = await db
@@ -107,12 +95,12 @@ export async function POST(request: NextRequest) {
value,
description: description || existing[0]!.description,
updatedAt: now,
updatedBy: session.user.id,
updatedBy: userId,
})
.where(eq(siteConfig.key, key))
.returning();
return NextResponse.json(updated[0]);
return success(updated[0], 200);
}
const newConfig = await db
@@ -124,36 +112,30 @@ export async function POST(request: NextRequest) {
category,
description: description || null,
updatedAt: now,
updatedBy: session.user.id,
updatedBy: userId,
})
.returning();
return NextResponse.json(newConfig[0], { status: 201 });
return success(newConfig[0], 201);
} catch (error) {
console.error('创建/更新配置失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
export async function PUT(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
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 });
return badRequest('无效的数据格式');
}
const now = new Date();
@@ -173,7 +155,7 @@ export async function PUT(request: NextRequest) {
value: config.value,
description: config.description || existing[0]!.description,
updatedAt: now,
updatedBy: session.user.id,
updatedBy: userId,
})
.where(eq(siteConfig.key, config.key))
.returning();
@@ -188,16 +170,50 @@ export async function PUT(request: NextRequest) {
category: 'general',
description: config.description || null,
updatedAt: now,
updatedBy: session.user.id,
updatedBy: userId,
})
.returning();
results.push(created[0]);
}
}
return NextResponse.json({ success: true, updated: results.length });
return success(results);
} catch (error) {
console.error('批量更新配置失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
export async function DELETE(request: NextRequest) {
try {
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return badRequest('缺少 key 参数');
}
const existing = await db
.select()
.from(siteConfig)
.where(eq(siteConfig.key, key))
.limit(1);
if (existing.length === 0) {
return notFound('配置不存在');
}
await db
.delete(siteConfig)
.where(eq(siteConfig.key, key));
return success({ success: true });
} catch (error) {
return handleApiError(error);
}
}
+26 -45
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } 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 { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { createAuditLog } from '@/lib/audit';
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
@@ -12,16 +12,10 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
@@ -33,7 +27,7 @@ export async function GET(
.limit(1);
if (item.length === 0) {
return NextResponse.json({ error: '内容不存在' }, { status: 404 });
return notFound('内容不存在');
}
const versions = await db
@@ -42,13 +36,12 @@ export async function GET(
.where(eq(contentVersions.contentId, id))
.orderBy(contentVersions.version);
return NextResponse.json({
return success({
...item[0],
versions,
});
} catch (error) {
console.error('获取内容详情失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
@@ -57,16 +50,11 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
const { id } = await params;
@@ -79,7 +67,7 @@ export async function PUT(
.limit(1);
if (existingContent.length === 0) {
return NextResponse.json({ error: '内容不存在' }, { status: 404 });
return notFound('内容不存在');
}
const current = existingContent[0]!;
@@ -107,7 +95,7 @@ export async function PUT(
},
to: body,
},
changedBy: session.user.id,
changedBy: userId,
changedAt: now,
});
@@ -138,7 +126,7 @@ export async function PUT(
.returning();
await createAuditLog({
userId: session.user.id,
userId,
action: 'update',
resourceType: 'content',
resourceId: id,
@@ -147,10 +135,9 @@ export async function PUT(
},
});
return NextResponse.json(updated[0]);
return success(updated[0]);
} catch (error) {
console.error('更新内容失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
@@ -159,16 +146,11 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
const { id } = await params;
@@ -180,14 +162,14 @@ export async function DELETE(
.limit(1);
if (existingContent.length === 0) {
return NextResponse.json({ error: '内容不存在' }, { status: 404 });
return notFound('内容不存在');
}
await db.delete(contentVersions).where(eq(contentVersions.contentId, id));
await db.delete(content).where(eq(content.id, id));
await createAuditLog({
userId: session.user.id,
userId,
action: 'delete',
resourceType: 'content',
resourceId: id,
@@ -196,9 +178,8 @@ export async function DELETE(
},
});
return NextResponse.json({ success: true });
return success({ success: true });
} catch (error) {
console.error('删除内容失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
+18 -31
View File
@@ -1,24 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { content } from '@/db/schema';
import { auth } from '@/lib/auth';
import { hasPermission } from '@/lib/auth/permissions';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { createAuditLog } from '@/lib/audit';
import { forbidden, badRequest, success, handleApiError, validationError } from '@/lib/api-response';
import { eq, desc, and, like, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export async function GET(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const { searchParams } = new URL(request.url);
@@ -61,7 +55,7 @@ export async function GET(request: NextRequest) {
const total = countResult[0]?.count || 0;
return NextResponse.json({
return success({
items,
pagination: {
page,
@@ -71,30 +65,24 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
console.error('获取内容列表失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
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 });
return validationError('缺少必要字段', { required: ['type', 'title', 'slug'] });
}
const existingContent = await db
@@ -104,7 +92,7 @@ export async function POST(request: NextRequest) {
.limit(1);
if (existingContent.length > 0) {
return NextResponse.json({ error: 'Slug 已存在' }, { status: 400 });
return badRequest('Slug 已存在');
}
const now = new Date();
@@ -122,7 +110,7 @@ export async function POST(request: NextRequest) {
tags: tags || [],
status: contentStatus || 'draft',
publishedAt: contentStatus === 'published' ? now : null,
authorId: session.user.id,
authorId: userId,
metadata: metadata || null,
createdAt: now,
updatedAt: now,
@@ -130,7 +118,7 @@ export async function POST(request: NextRequest) {
.returning();
await createAuditLog({
userId: session.user.id,
userId,
action: 'create',
resourceType: 'content',
resourceId: newContent[0]!.id,
@@ -141,9 +129,8 @@ export async function POST(request: NextRequest) {
},
});
return NextResponse.json(newContent[0], { status: 201 });
return success(newContent[0], 201);
} catch (error) {
console.error('创建内容失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
+23 -33
View File
@@ -1,21 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { hasPermission } from '@/lib/auth/permissions';
import { NextRequest } from 'next/server';
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
import { createAuditLog } from '@/lib/audit';
import { uploadFile, deleteFile } from '@/lib/upload';
import { forbidden, badRequest, notFound, success, handleApiError } from '@/lib/api-response';
export async function POST(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
const formData = await request.formData();
@@ -23,16 +18,16 @@ export async function POST(request: NextRequest) {
const type = (formData.get('type') as 'image' | 'document') || 'image';
if (!file) {
return NextResponse.json({ error: '未找到文件' }, { status: 400 });
return badRequest('未找到文件');
}
const result = await uploadFile(file, {
type,
userId: session.user.id,
userId,
});
await createAuditLog({
userId: session.user.id,
userId,
action: 'upload',
resourceType: 'file',
resourceId: result.id,
@@ -44,7 +39,7 @@ export async function POST(request: NextRequest) {
},
});
return NextResponse.json({
return success({
success: true,
file: result,
});
@@ -52,43 +47,38 @@ export async function POST(request: NextRequest) {
console.error('文件上传失败:', error);
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 400 });
return badRequest(error.message);
}
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
export async function DELETE(request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
const userId = await getAdminUserId();
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 });
if (!isAdmin || !userId) {
return forbidden();
}
const { searchParams } = new URL(request.url);
const fileUrl = searchParams.get('url');
if (!fileUrl) {
return NextResponse.json({ error: '缺少文件 URL' }, { status: 400 });
return badRequest('缺少文件 URL');
}
const success = await deleteFile(fileUrl);
const result = await deleteFile(fileUrl);
if (!success) {
return NextResponse.json({ error: '文件不存在或删除失败' }, { status: 404 });
if (!result) {
return notFound('文件不存在或删除失败');
}
return NextResponse.json({ success: true });
return success({ success: true });
} catch (error) {
console.error('文件删除失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
+39 -61
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { db } from '@/db';
import { users } from '@/db/schema';
import { auth } from '@/lib/auth';
import { hasPermission } from '@/lib/auth/permissions';
import { checkIsAdmin } from '@/lib/auth/check-permission';
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
@@ -11,26 +11,24 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { isAdmin, userId } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
if (id !== userId) {
return forbidden('只能查看自己的信息');
}
const user = await db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
@@ -39,13 +37,12 @@ export async function GET(
.limit(1);
if (user.length === 0) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
return notFound('用户不存在');
}
return NextResponse.json({ user: user[0] });
return success({ user: user[0] });
} catch (error) {
console.error('获取用户详情失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
@@ -54,21 +51,20 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { isAdmin, userId } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
if (id !== userId) {
return forbidden('只能修改自己的信息');
}
const body = await request.json();
const { email, name, password, role } = body;
const { email, name, password } = body;
const existingUser = await db
.select()
@@ -77,16 +73,15 @@ export async function PUT(
.limit(1);
if (existingUser.length === 0) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
return notFound('用户不存在');
}
const updateData: Record<string, any> = {
const updateData: Record<string, unknown> = {
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);
}
@@ -97,18 +92,16 @@ export async function PUT(
.where(eq(users.id, id))
.returning();
return NextResponse.json({
return success({
user: {
id: updated[0]!.id,
email: updated[0]!.email,
name: updated[0]!.name,
role: updated[0]!.role,
createdAt: updated[0]!.createdAt,
}
isAdmin: updated[0]!.isAdmin,
},
});
} catch (error) {
console.error('更新用户失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
@@ -117,39 +110,24 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { isAdmin, userId } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const { id } = await params;
if (session.user.id === id) {
return NextResponse.json({ error: '不能删除自己的账号' }, { status: 400 });
if (id !== userId) {
return forbidden('不能删除其他用户');
}
const existingUser = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
await db
.delete(users)
.where(eq(users.id, id));
if (existingUser.length === 0) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
await db.delete(users).where(eq(users.id, id));
return NextResponse.json({ success: true });
return success({ success: true });
} catch (error) {
console.error('删除用户失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
return handleApiError(error);
}
}
+11 -80
View File
@@ -1,24 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } 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';
import { checkIsAdmin } from '@/lib/auth/check-permission';
import { forbidden, success, handleApiError } from '@/lib/api-response';
import { desc } from 'drizzle-orm';
export async function GET(_request: NextRequest) {
try {
const session = await auth();
const { isAdmin } = await checkIsAdmin();
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 });
if (!isAdmin) {
return forbidden();
}
const allUsers = await db
@@ -26,77 +18,16 @@ export async function GET(_request: NextRequest) {
id: users.id,
email: users.email,
name: users.name,
role: users.role,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.orderBy(users.createdAt);
.orderBy(desc(users.createdAt));
return NextResponse.json({ users: allUsers });
return success({ 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 });
return handleApiError(error);
}
}
+20
View File
@@ -0,0 +1,20 @@
export { DataParticleFlow } from './data-particle-flow';
export { SubtleDots } from './subtle-dots';
export { SubtleParticles } from './subtle-particles';
export { ParticleGalaxy } from './particle-galaxy';
export { MouseInteractiveParticles } from './mouse-interactive-particles';
export { GradientFlow } from './gradient-flow';
export { GradientAnimation } from './gradient-animation';
export { GradientOrbs } from './gradient-orbs';
export { GradientGrid } from './gradient-grid';
export { TechGridFlow } from './tech-grid-flow';
export { MeshGradient } from './mesh-gradient';
export { InkTechFusion } from './ink-tech-fusion';
export { GridLines } from './grid-lines';
export { GlowEffect } from './glow-effect';
export { GeometricShapes } from './geometric-shapes';
export { GeometricAbstract } from './geometric-abstract';
export { FluidWaveBackground } from './fluid-wave-background';
export { AdvancedFloatingEffects } from './advanced-floating-effects';
export { ParallaxEffect } from './parallax-effect';
export { SealAnimationEnhanced } from './seal-animation-enhanced';
@@ -0,0 +1,220 @@
'use client';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
import { MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
import { COMPANY_INFO, STATS } from '@/lib/constants';
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
interface HeroContentProps {
isVisible: boolean;
}
const features = [
{ icon: Shield, text: '安全可靠' },
{ icon: Zap, text: '高效便捷' },
{ icon: Award, text: '专业服务' },
];
function scrollTo(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
scrollTo(id);
}
}
export function HeroContent({ isVisible }: HeroContentProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={isVisible ? { opacity: 1, y: 0, scale: 1 } : {}}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="mb-8"
>
<span className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[#1C1C1C]/20 bg-[#F5F5F5] text-[#1C1C1C] text-sm font-medium">
</span>
</motion.div>
);
}
export function HeroTitle({ isVisible }: HeroContentProps) {
return (
<motion.h1
id="hero-heading"
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-calligraphy"
style={{
fontWeight: 'normal',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility'
}}
>
{COMPANY_INFO.shortName}
</motion.h1>
);
}
export function HeroDescription(_props: HeroContentProps) {
return (
<div className="mb-10">
<BlurReveal delay={0.3}>
<p className="text-xl sm:text-2xl text-[#4A5568] mb-4">
<span className="font-semibold bg-gradient-to-r from-[#C41E3A] via-[#E04A68] to-[#C41E3A] bg-clip-text text-transparent">
</span>
</p>
</BlurReveal>
<BlurReveal delay={0.4}>
<p className="text-lg text-[#718096] max-w-2xl mx-auto leading-relaxed">
</p>
</BlurReveal>
</div>
);
}
export function HeroButtons({ isVisible }: HeroContentProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
>
<MagneticButton strength={0.4}>
<Link href="/contact">
<SealButton size="lg" className="min-w-45">
<ArrowRight className="w-4 h-4 ml-2" />
</SealButton>
</Link>
</MagneticButton>
<MagneticButton strength={0.4}>
<RippleButton
size="lg"
variant="outline"
onClick={() => scrollTo('about')}
onKeyDown={(e) => handleKeyDown(e, 'about')}
className="min-w-45"
>
</RippleButton>
</MagneticButton>
</motion.div>
);
}
export function HeroFeatures({ isVisible }: HeroContentProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.35 }}
className="flex flex-wrap gap-4 justify-center mb-16"
>
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.9 }}
animate={isVisible ? { opacity: 1, scale: 1 } : {}}
transition={{ duration: 0.4, delay: 0.4 + index * 0.1 }}
whileHover={{ scale: 1.05, y: -2 }}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-[#FAFAFA] border border-[#E5E5E5] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-md cursor-default"
>
<feature.icon className="w-4 h-4 text-[#C41E3A]" />
<span className="text-sm text-[#3D3D3D]">{feature.text}</span>
</motion.div>
))}
</motion.div>
);
}
export function HeroStats() {
const [statsVisible, setStatsVisible] = useState(false);
useEffect(() => {
const statsEl = document.getElementById('stats-section');
if (!statsEl) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setStatsVisible(true);
}
},
{ threshold: 0.5 }
);
observer.observe(statsEl);
return () => observer.disconnect();
}, []);
return (
<motion.div
id="stats-section"
initial={{ opacity: 0, y: 20 }}
animate={statsVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.4 }}
className="pt-16 border-t border-[#E2E8F0]"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat, index) => (
<HeroStatItem
key={stat.label}
stat={stat}
index={index}
shouldAnimate={statsVisible}
/>
))}
</div>
</motion.div>
);
}
function HeroStatItem({ stat, index, shouldAnimate }: {
stat: { value: string; label: string };
index: number;
shouldAnimate: boolean;
}) {
const numericValue = parseInt(stat.value.replace(/\D/g, ''));
const suffix = stat.value.replace(/[\d]/g, '');
return (
<motion.div
className="group cursor-default text-center"
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={shouldAnimate ? { opacity: 1, y: 0, scale: 1 } : {}}
transition={{ duration: 0.5, delay: index * 0.1, type: 'spring', stiffness: 100 }}
whileHover={{ scale: 1.05, y: -5 }}
>
<div className="text-4xl sm:text-5xl font-bold text-[#C41E3A] mb-3">
{shouldAnimate ? (
<CounterWithEffect
end={numericValue}
suffix={suffix}
effect="bounce"
duration={2000}
/>
) : (
<span className="text-[#CBD5E0]">0{suffix}</span>
)}
</div>
<div className="text-sm text-[#718096] group-hover:text-[#4A5568] transition-colors">
{stat.label}
</div>
</motion.div>
);
}
+7 -189
View File
@@ -1,13 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import dynamic from 'next/dynamic';
import { motion } from 'framer-motion';
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
import { GradientText, MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
import { COMPANY_INFO, STATS } from '@/lib/constants';
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
import { HeroContent, HeroTitle, HeroDescription, HeroButtons, HeroFeatures, HeroStats } from './hero-section-atoms';
const InkBackground = dynamic(
() => import('@/components/ui/ink-decoration').then(mod => ({ default: mod.InkBackground })),
@@ -24,16 +19,9 @@ const SubtleDots = dynamic(
{ ssr: false }
);
const features = [
{ icon: Shield, text: '安全可靠' },
{ icon: Zap, text: '高效便捷' },
{ icon: Award, text: '专业服务' },
];
export function HeroSection() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const [statsVisible, setStatsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
@@ -52,37 +40,6 @@ export function HeroSection() {
return () => observer.disconnect();
}, []);
useEffect(() => {
const statsEl = document.getElementById('stats-section');
if (!statsEl) {return;}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setStatsVisible(true);
}
},
{ threshold: 0.5 }
);
observer.observe(statsEl);
return () => observer.disconnect();
}, []);
const handleScrollTo = (id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, id: string) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleScrollTo(id);
}
};
return (
<section
id="home"
@@ -102,153 +59,14 @@ export function HeroSection() {
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10">
<div className="max-w-4xl mx-auto text-center">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={isVisible ? { opacity: 1, y: 0, scale: 1 } : {}}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="mb-8"
>
<span className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[#1C1C1C]/20 bg-[#F5F5F5] text-[#1C1C1C] text-sm font-medium">
</span>
</motion.div>
<motion.h1
id="hero-heading"
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-calligraphy"
style={{
fontWeight: 'normal',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility'
}}
>
{COMPANY_INFO.shortName}
</motion.h1>
<BlurReveal delay={0.3}>
<p className="text-xl sm:text-2xl text-[#4A5568] mb-4">
<GradientText colors={['#C41E3A', '#E04A68', '#C41E3A']} duration={4}>
</GradientText>
</p>
</BlurReveal>
<BlurReveal delay={0.4}>
<p className="text-lg text-[#718096] mb-10 max-w-2xl mx-auto leading-relaxed">
</p>
</BlurReveal>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
>
<MagneticButton strength={0.4}>
<Link href="/contact">
<SealButton
size="lg"
className="min-w-45"
>
<ArrowRight className="w-4 h-4 ml-2" />
</SealButton>
</Link>
</MagneticButton>
<MagneticButton strength={0.4}>
<RippleButton
size="lg"
variant="outline"
onClick={() => handleScrollTo('about')}
onKeyDown={(e) => handleKeyDown(e, 'about')}
className="min-w-45"
>
</RippleButton>
</MagneticButton>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.35 }}
className="flex flex-wrap gap-4 justify-center mb-16"
>
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.9 }}
animate={isVisible ? { opacity: 1, scale: 1 } : {}}
transition={{ duration: 0.4, delay: 0.4 + index * 0.1 }}
whileHover={{ scale: 1.05, y: -2 }}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-[#FAFAFA] border border-[#E5E5E5] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-md cursor-default"
>
<feature.icon className="w-4 h-4 text-[#C41E3A]" />
<span className="text-sm text-[#3D3D3D]">{feature.text}</span>
</motion.div>
))}
</motion.div>
<motion.div
id="stats-section"
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.4 }}
className="pt-16 border-t border-[#E2E8F0]"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{STATS.map((stat, index) => (
<StatItem
key={stat.label}
stat={stat}
index={index}
shouldAnimate={statsVisible}
/>
))}
</div>
</motion.div>
<HeroContent isVisible={isVisible} />
<HeroTitle isVisible={isVisible} />
<HeroDescription isVisible={isVisible} />
<HeroButtons isVisible={isVisible} />
<HeroFeatures isVisible={isVisible} />
<HeroStats />
</div>
</div>
</section>
);
}
function StatItem({ stat, index, shouldAnimate }: {
stat: { value: string; label: string };
index: number;
shouldAnimate: boolean;
}) {
const numericValue = parseInt(stat.value.replace(/\D/g, ''));
const suffix = stat.value.replace(/[\d]/g, '');
return (
<motion.div
className="group cursor-default text-center"
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={shouldAnimate ? { opacity: 1, y: 0, scale: 1 } : {}}
transition={{ duration: 0.5, delay: index * 0.1, type: 'spring', stiffness: 100 }}
whileHover={{ scale: 1.05, y: -5 }}
>
<div className="text-4xl sm:text-5xl font-bold text-[#C41E3A] mb-3">
{shouldAnimate ? (
<CounterWithEffect
end={numericValue}
suffix={suffix}
effect="bounce"
duration={2000}
/>
) : (
<span className="text-[#CBD5E0]">0{suffix}</span>
)}
</div>
<div className="text-sm text-[#718096] group-hover:text-[#4A5568] transition-colors">
{stat.label}
</div>
</motion.div>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const users = sqliteTable('users', {
email: text('email').notNull().unique(),
passwordHash: text('password_hash'),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'editor', 'viewer'] }).notNull().default('editor'),
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
avatar: text('avatar'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
+1 -1
View File
@@ -21,7 +21,7 @@ async function seed() {
email: 'admin@novalon.cn',
passwordHash: hashedPassword,
name: '系统管理员',
role: 'admin',
isAdmin: true,
createdAt: new Date(),
updatedAt: new Date(),
});
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
export type ErrorCode =
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'VALIDATION_ERROR'
| 'INTERNAL_ERROR'
| 'BAD_REQUEST';
export interface ApiError {
error: string;
code: ErrorCode;
details?: Record<string, unknown>;
}
const ERROR_MESSAGES: Record<ErrorCode, string> = {
UNAUTHORIZED: '未授权,请先登录',
FORBIDDEN: '无权限执行此操作',
NOT_FOUND: '请求的资源不存在',
VALIDATION_ERROR: '数据验证失败',
INTERNAL_ERROR: '服务器内部错误',
BAD_REQUEST: '请求参数错误',
};
export function unauthorized(message?: string): NextResponse<ApiError> {
return NextResponse.json(
{ error: message || ERROR_MESSAGES.UNAUTHORIZED, code: 'UNAUTHORIZED' },
{ status: 401 }
);
}
export function forbidden(message?: string): NextResponse<ApiError> {
return NextResponse.json(
{ error: message || ERROR_MESSAGES.FORBIDDEN, code: 'FORBIDDEN' },
{ status: 403 }
);
}
export function notFound(message?: string): NextResponse<ApiError> {
return NextResponse.json(
{ error: message || ERROR_MESSAGES.NOT_FOUND, code: 'NOT_FOUND' },
{ status: 404 }
);
}
export function validationError(message: string, details?: Record<string, unknown>): NextResponse<ApiError> {
return NextResponse.json(
{ error: message, code: 'VALIDATION_ERROR', details },
{ status: 400 }
);
}
export function badRequest(message: string): NextResponse<ApiError> {
return NextResponse.json(
{ error: message, code: 'BAD_REQUEST' },
{ status: 400 }
);
}
export function internalError(message = '服务器错误'): NextResponse<ApiError> {
console.error(message);
return NextResponse.json(
{ error: ERROR_MESSAGES.INTERNAL_ERROR, code: 'INTERNAL_ERROR' },
{ status: 500 }
);
}
export function success<T>(data: T, status = 200): NextResponse<T> {
return NextResponse.json(data, { status });
}
export function handleApiError(error: unknown): NextResponse<ApiError> {
console.error('API Error:', error);
if (error instanceof Error) {
if (error.message.includes('未授权')) {
return unauthorized(error.message);
}
if (error.message.includes('无权限')) {
return forbidden(error.message);
}
if (error.message.includes('不存在')) {
return notFound(error.message);
}
}
return internalError();
}
+3 -3
View File
@@ -42,7 +42,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isAdmin: user.isAdmin,
};
},
}),
@@ -51,14 +51,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.isAdmin = user.isAdmin;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
session.user.isAdmin = token.isAdmin as boolean;
}
return session;
},
+16 -17
View File
@@ -1,38 +1,37 @@
import { auth } from '../auth';
import { hasPermission, Role, Resource, Action } from './permissions';
import { isAdminUser } from './permissions';
export async function checkPermission(
resource: Resource,
action: Action
): Promise<{ allowed: boolean; userId?: string; role?: Role }> {
export async function checkIsAdmin(): Promise<{ isAdmin: boolean; userId?: string }> {
const session = await auth();
if (!session || !session.user) {
return { allowed: false };
return { isAdmin: false };
}
const userRole = session.user.role as Role;
const allowed = hasPermission(userRole, resource, action);
const isAdmin = isAdminUser(session.user.isAdmin as boolean | undefined);
return {
allowed,
isAdmin,
userId: session.user.id,
role: userRole,
};
}
export async function requirePermission(
resource: Resource,
action: Action
): Promise<{ userId: string; role: Role }> {
const result = await checkPermission(resource, action);
export async function requireAdmin(): Promise<{ userId: string }> {
const result = await checkIsAdmin();
if (!result.allowed) {
if (!result.isAdmin) {
throw new Error('无权限执行此操作');
}
return {
userId: result.userId!,
role: result.role!,
};
}
export async function getAdminUserId(): Promise<string | null> {
const session = await auth();
if (!session?.user) {
return null;
}
return session.user.id;
}
+2 -37
View File
@@ -1,38 +1,3 @@
export const PERMISSIONS = {
admin: {
content: ['create', 'read', 'update', 'delete', 'publish'],
config: ['read', 'update'],
users: ['create', 'read', 'update', 'delete'],
logs: ['read'],
},
editor: {
content: ['create', 'read', 'update', 'publish'],
config: ['read'],
users: [],
logs: ['read'],
},
viewer: {
content: ['read'],
config: ['read'],
users: [],
logs: [],
},
} as const;
export type Role = keyof typeof PERMISSIONS;
export type Resource = keyof typeof PERMISSIONS.admin;
export type Action = 'create' | 'read' | 'update' | 'delete' | 'publish';
export function hasPermission(
role: Role,
resource: Resource,
action: Action
): boolean {
const permissions = PERMISSIONS[role];
if (!permissions) return false;
const resourcePermissions = permissions[resource];
if (!resourcePermissions) return false;
return resourcePermissions.includes(action as never);
export function isAdminUser(isAdmin: boolean | undefined): boolean {
return isAdmin === true;
}
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom';
declare module 'expect' {
interface Matchers<R, T> extends jest.Matchers<R, T> {}
}
+3 -3
View File
@@ -4,18 +4,18 @@ declare module 'next-auth' {
interface Session {
user: {
id: string;
role: string;
isAdmin: boolean;
} & DefaultSession['user'];
}
interface User {
role: string;
isAdmin: boolean;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: string;
isAdmin: boolean;
}
}