feat: 重构用户角色系统为管理员标识
- 将用户角色字段从role改为is_admin布尔值 - 更新相关API权限检查逻辑 - 修改数据库schema和迁移文件 - 调整前端用户显示逻辑 - 添加API响应工具函数 - 优化权限检查中间件 - 重构英雄组件为原子组件
This commit is contained in:
@@ -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`);
|
||||
@@ -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,6 +8,13 @@
|
||||
"when": 1772974841798,
|
||||
"tag": "0000_white_justice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773202935722,
|
||||
"tag": "0001_clammy_toro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('next-auth', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -21,7 +21,7 @@ async function seed() {
|
||||
email: 'admin@novalon.cn',
|
||||
passwordHash: hashedPassword,
|
||||
name: '系统管理员',
|
||||
role: 'admin',
|
||||
isAdmin: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
declare module 'expect' {
|
||||
interface Matchers<R, T> extends jest.Matchers<R, T> {}
|
||||
}
|
||||
Vendored
+3
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user