- 修复API测试认证问题:创建全局认证设置,更新Playwright配置 - 优化回归测试稳定性:增加超时时间到15秒,修复定位器 - 创建Woodpecker CI工作流:CI、部署和质量门禁配置 - 添加Jest配置和测试脚本 - 移除登录页面的默认账号密码显示(安全问题修复)
46 KiB
可配置化 CMS 系统执行计划
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
目标: 为 Novalon Website 构建可配置的内容管理系统,使用 Resend 邮件服务、本地文件存储、域名 novalon.cn
架构: Next.js 16 + SQLite + Drizzle ORM + NextAuth.js + Resend
技术栈: Next.js, React, TypeScript, SQLite, Drizzle ORM, NextAuth.js, Tiptap, Resend
关键配置:
- 邮件服务:Resend (API Key: re_icMNpBzS_DL9GirDmhG5PbNU6PLRWvUtY)
- 文件存储:本地存储 (uploads/)
- 域名:novalon.cn
前置准备
环境要求
- Node.js 18+
- npm
- Git
设计文档
- 设计文档:
docs/plans/2026-03-08-configurable-cms-design.md - 详细实施计划:
docs/plans/2026-03-08-configurable-cms-implementation.md
阶段一:基础架构搭建(预计 2 天)
Task 1: 安装依赖包
文件:
- 修改:
package.json
步骤 1: 安装数据库相关依赖
运行命令:
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
步骤 2: 安装认证相关依赖
运行命令:
npm install next-auth@beta @auth/drizzle-adapter
npm install bcryptjs
npm install -D @types/bcryptjs
步骤 3: 安装富文本编辑器
运行命令:
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/pm
步骤 4: 安装邮件和工具库
运行命令:
npm install resend nanoid date-fns
npm install -D @types/nanoid
步骤 5: 验证安装
运行命令:
npm list drizzle-orm next-auth @tiptap/react resend
预期输出:所有依赖包版本正确显示
步骤 6: 提交
运行命令:
git add package.json package-lock.json
git commit -m "chore: 添加 CMS 系统所需依赖包
- 数据库: drizzle-orm, @libsql/client
- 认证: next-auth, bcryptjs
- 编辑器: @tiptap/react
- 邮件: resend
- 工具: nanoid, date-fns"
Task 2: 配置环境变量
文件:
- 创建:
.env.local - 修改:
.gitignore
步骤 1: 创建环境变量文件
创建 .env.local 文件,内容如下:
# Database
DATABASE_URL="file:./data.db"
# NextAuth
NEXTAUTH_SECRET="novalon-cms-secret-key-2026-change-in-production"
NEXTAUTH_URL="http://localhost:3000"
# Resend Email
RESEND_API_KEY="re_icMNpBzS_DL9GirDmhG5PbNU6PLRWvUtY"
EMAIL_FROM="noreply@novalon.cn"
# Admin (初始管理员账号)
ADMIN_EMAIL="admin@novalon.cn"
ADMIN_PASSWORD="admin123456"
# File Upload
UPLOAD_DIR="./uploads"
MAX_FILE_SIZE="10485760"
# Site
SITE_URL="https://novalon.cn"
SITE_NAME="睿新致遠"
步骤 2: 更新 .gitignore
在 .gitignore 文件中添加:
# Database
*.db
*.db-journal
data.db
# Environment
.env.local
.env.*.local
# Uploads
uploads/
步骤 3: 提交
运行命令:
git add .gitignore
git commit -m "chore: 配置环境变量和 .gitignore
- 添加数据库、认证、邮件配置
- 配置 Resend API Key
- 配置本地文件上传目录
- 忽略敏感文件"
Task 3: 配置数据库连接
文件:
- 创建:
src/db/index.ts
步骤 1: 创建数据库连接文件
创建 src/db/index.ts 文件,内容如下:
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
const client = createClient({
url: process.env.DATABASE_URL || 'file:./data.db',
});
export const db = drizzle(client);
步骤 2: 提交
运行命令:
git add src/db/index.ts
git commit -m "feat: 配置数据库连接"
Task 4: 定义数据库 Schema
文件:
- 创建:
src/db/schema.ts - 创建:
drizzle.config.ts
步骤 1: 创建 Schema 文件
创建 src/db/schema.ts 文件,内容如下:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash'),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'editor', 'viewer'] }).notNull().default('editor'),
avatar: text('avatar'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const content = sqliteTable('content', {
id: text('id').primaryKey(),
type: text('type', { enum: ['news', 'product', 'service', 'case'] }).notNull(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
content: text('content').notNull(),
coverImage: text('cover_image'),
category: text('category'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
publishedAt: integer('published_at', { mode: 'timestamp' }),
authorId: text('author_id').notNull().references(() => users.id),
sortOrder: integer('sort_order').default(0),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const contentVersions = sqliteTable('content_versions', {
id: text('id').primaryKey(),
contentId: text('content_id').notNull().references(() => content.id),
version: integer('version').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
changes: text('changes', { mode: 'json' }).$type<Record<string, any>>(),
changedBy: text('changed_by').notNull().references(() => users.id),
changedAt: integer('changed_at', { mode: 'timestamp' }).notNull(),
});
export const siteConfig = sqliteTable('site_config', {
id: text('id').primaryKey(),
key: text('key').notNull().unique(),
value: text('value', { mode: 'json' }).notNull(),
category: text('category', { enum: ['feature', 'style', 'seo', 'general'] }).notNull(),
description: text('description'),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
updatedBy: text('updated_by').references(() => users.id),
});
export const auditLogs = sqliteTable('audit_logs', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id),
action: text('action', { enum: ['create', 'update', 'delete', 'publish', 'login'] }).notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
details: text('details', { mode: 'json' }).$type<Record<string, any>>(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
content: many(content),
versions: many(contentVersions),
logs: many(auditLogs),
}));
export const contentRelations = relations(content, ({ one, many }) => ({
author: one(users, {
fields: [content.authorId],
references: [users.id],
}),
versions: many(contentVersions),
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Content = typeof content.$inferSelect;
export type NewContent = typeof content.$inferInsert;
export type SiteConfig = typeof siteConfig.$inferSelect;
export type NewSiteConfig = typeof siteConfig.$inferInsert;
步骤 2: 创建 Drizzle 配置文件
创建 drizzle.config.ts 文件,内容如下:
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
driver: 'libsql',
dbCredentials: {
url: process.env.DATABASE_URL || 'file:./data.db',
},
} satisfies Config;
步骤 3: 更新 package.json scripts
在 package.json 的 scripts 中添加:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
}
}
步骤 4: 提交
运行命令:
git add src/db/schema.ts drizzle.config.ts package.json
git commit -m "feat: 定义数据库 Schema
- 用户表 (users)
- 内容表 (content)
- 版本历史表 (content_versions)
- 站点配置表 (site_config)
- 操作日志表 (audit_logs)"
Task 5: 生成并执行数据库迁移
文件:
- 创建:
drizzle/0000_*.sql(自动生成) - 创建:
data.db(自动创建)
步骤 1: 生成迁移文件
运行命令:
npm run db:generate
预期输出:在 drizzle/ 目录生成 SQL 迁移文件
步骤 2: 执行迁移
运行命令:
npm run db:push
预期输出:创建 data.db 文件,包含所有表
步骤 3: 验证表结构
运行命令:
sqlite3 data.db ".tables"
预期输出:显示所有表名 (users, content, content_versions, site_config, audit_logs)
步骤 4: 提交
运行命令:
git add drizzle/
git commit -m "feat: 生成数据库迁移文件"
Task 6: 创建数据库种子数据
文件:
- 创建:
src/db/seed.ts
步骤 1: 创建种子数据脚本
创建 src/db/seed.ts 文件,内容如下:
import { db } from './index';
import { users, siteConfig } from './schema';
import { nanoid } from 'nanoid';
import bcrypt from 'bcryptjs';
async function seed() {
console.log('🌱 开始种子数据...');
try {
// 创建管理员用户
const hashedPassword = await bcrypt.hash('admin123456', 10);
await db.insert(users).values({
id: nanoid(),
email: 'admin@novalon.cn',
passwordHash: hashedPassword,
name: '系统管理员',
role: 'admin',
createdAt: new Date(),
updatedAt: new Date(),
});
console.log('✅ 创建管理员用户: admin@novalon.cn');
// 创建默认配置
const defaultConfigs = [
{
id: nanoid(),
key: 'feature_news',
value: {
enabled: true,
displayCount: 6,
categories: ['公司新闻', '产品发布', '合作动态', '行业资讯'],
sortOrder: 'desc',
},
category: 'feature',
description: '新闻模块配置',
updatedAt: new Date(),
},
{
id: nanoid(),
key: 'feature_products',
value: {
enabled: true,
showPricing: true,
featuredProducts: ['erp', 'crm'],
},
category: 'feature',
description: '产品模块配置',
updatedAt: new Date(),
},
{
id: nanoid(),
key: 'feature_services',
value: {
enabled: true,
items: ['software', 'cloud', 'data', 'security'],
},
category: 'feature',
description: '服务模块配置',
updatedAt: new Date(),
},
{
id: nanoid(),
key: 'seo_default',
value: {
title: '四川睿新致远科技有限公司 - 企业数字化转型服务商',
description: '以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者',
keywords: ['数字化转型', '软件开发', '云服务', '数据分析', '信息安全'],
},
category: 'seo',
description: '默认 SEO 配置',
updatedAt: new Date(),
},
];
for (const config of defaultConfigs) {
await db.insert(siteConfig).values(config);
console.log(`✅ 创建配置: ${config.key}`);
}
console.log('🎉 种子数据完成!');
console.log('📝 管理员账号: admin@novalon.cn');
console.log('🔑 默认密码: admin123456');
} catch (error) {
console.error('❌ 种子数据失败:', error);
process.exit(1);
}
}
seed();
步骤 2: 执行种子数据
运行命令:
npm run db:seed
预期输出:创建管理员用户和默认配置
步骤 3: 验证数据
运行命令:
sqlite3 data.db "SELECT email, role FROM users"
预期输出:显示管理员邮箱和角色
步骤 4: 提交
运行命令:
git add src/db/seed.ts
git commit -m "feat: 添加数据库种子数据脚本
- 创建管理员用户 (admin@novalon.cn)
- 创建默认功能配置
- 创建默认 SEO 配置"
Task 7: 配置 NextAuth.js
文件:
- 创建:
src/lib/auth.ts - 创建:
src/app/api/auth/[...nextauth]/route.ts - 创建:
src/providers/session-provider.tsx - 创建:
src/types/next-auth.d.ts - 修改:
src/app/layout.tsx
步骤 1: 创建认证配置
创建 src/lib/auth.ts 文件,内容如下:
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import EmailProvider from 'next-auth/providers/email';
import { Resend } from 'resend';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
const resend = new Resend(process.env.RESEND_API_KEY);
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: '邮箱密码',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db
.select()
.from(users)
.where(eq(users.email, credentials.email))
.limit(1);
if (user.length === 0) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password,
user[0].passwordHash || ''
);
if (!isValid) {
return null;
}
return {
id: user[0].id,
email: user[0].email,
name: user[0].name,
role: user[0].role,
};
},
}),
EmailProvider({
server: {},
from: process.env.EMAIL_FROM || 'noreply@novalon.cn',
sendVerificationRequest: async ({ identifier: email, url }) => {
try {
await resend.emails.send({
from: process.env.EMAIL_FROM || 'noreply@novalon.cn',
to: email,
subject: '睿新致遠 - 登录验证链接',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #C41E3A;">睿新致遠管理后台登录</h2>
<p>您好!</p>
<p>您收到这封邮件是因为您请求登录睿新致遠管理后台。</p>
<p>请点击下方按钮完成登录:</p>
<a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #C41E3A; color: white; text-decoration: none; border-radius: 6px; margin: 16px 0;">
立即登录
</a>
<p style="color: #666; font-size: 14px;">如果您没有请求此链接,请忽略此邮件。</p>
<hr style="margin: 24px 0; border: none; border-top: 1px solid #eee;" />
<p style="color: #999; font-size: 12px;">四川睿新致远科技有限公司</p>
</div>
`,
});
} catch (error) {
console.error('发送邮件失败:', error);
throw new Error('发送邮件失败');
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
pages: {
signIn: '/admin/login',
error: '/admin/login',
},
session: {
strategy: 'jwt',
},
};
步骤 2: 创建 API Route
创建 src/app/api/auth/[...nextauth]/route.ts 文件,内容如下:
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
步骤 3: 创建 Session Provider
创建 src/providers/session-provider.tsx 文件,内容如下:
'use client';
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';
export function SessionProvider({ children }: { children: ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}
步骤 4: 创建类型定义
创建 src/types/next-auth.d.ts 文件,内容如下:
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession['user'];
}
interface User {
role: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: string;
}
}
步骤 5: 更新根布局
修改 src/app/layout.tsx,在 <body> 内添加 SessionProvider:
import { SessionProvider } from '@/providers/session-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
);
}
步骤 6: 提交
运行命令:
git add src/lib/auth.ts src/app/api/auth/ src/providers/ src/types/next-auth.d.ts src/app/layout.tsx
git commit -m "feat: 配置 NextAuth.js 认证系统
- 支持邮箱密码登录
- 支持 Magic Link 登录(Resend)
- 配置 Session Provider
- 添加 TypeScript 类型定义"
Task 8: 创建权限检查工具
文件:
- 创建:
src/lib/auth/permissions.ts - 创建:
src/lib/auth/check-permission.ts
步骤 1: 定义权限矩阵
创建 src/lib/auth/permissions.ts 文件,内容如下:
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);
}
步骤 2: 创建权限检查函数
创建 src/lib/auth/check-permission.ts 文件,内容如下:
import { getServerSession } from 'next-auth';
import { authOptions } from './auth';
import { hasPermission, Role, Resource, Action } from './permissions';
export async function checkPermission(
resource: Resource,
action: Action
): Promise<{ allowed: boolean; userId?: string; role?: Role }> {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return { allowed: false };
}
const userRole = session.user.role as Role;
const allowed = hasPermission(userRole, resource, action);
return {
allowed,
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);
if (!result.allowed) {
throw new Error('无权限执行此操作');
}
return {
userId: result.userId!,
role: result.role!,
};
}
步骤 3: 提交
运行命令:
git add src/lib/auth/
git commit -m "feat: 创建权限检查工具
- 定义权限矩阵(admin/editor/viewer)
- 实现权限检查函数
- 实现权限要求函数"
阶段二:内容管理 API(预计 2 天)
Task 9: 创建内容 CRUD API
文件:
- 创建:
src/lib/validators/content.ts - 创建:
src/app/api/v1/content/route.ts - 创建:
src/app/api/v1/content/[id]/route.ts
步骤 1: 创建验证 Schema
创建 src/lib/validators/content.ts 文件,内容如下:
import { z } from 'zod';
export const ContentCreateSchema = z.object({
type: z.enum(['news', 'product', 'service', 'case']),
title: z.string().min(1).max(200),
slug: z.string().min(1).max(200).regex(/^[a-z0-9-]+$/),
excerpt: z.string().max(500).optional(),
content: z.string().min(1),
coverImage: z.string().url().optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
status: z.enum(['draft', 'published', 'archived']).default('draft'),
publishedAt: z.coerce.date().optional(),
sortOrder: z.number().int().optional(),
metadata: z.record(z.any()).optional(),
});
export const ContentUpdateSchema = ContentCreateSchema.partial();
export const ContentQuerySchema = z.object({
type: z.enum(['news', 'product', 'service', 'case']).optional(),
status: z.enum(['draft', 'published', 'archived']).optional(),
category: z.string().optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(10),
sort: z.string().default('-publishedAt'),
});
步骤 2: 创建列表和创建 API
创建 src/app/api/v1/content/route.ts 文件,内容如下:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { content } from '@/db/schema';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { ContentQuerySchema, ContentCreateSchema } from '@/lib/validators/content';
import { checkPermission } from '@/lib/auth/check-permission';
import { nanoid } from 'nanoid';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const query = ContentQuerySchema.parse(Object.fromEntries(searchParams));
const whereConditions = [];
if (query.type) whereConditions.push(eq(content.type, query.type));
if (query.status) whereConditions.push(eq(content.status, query.status));
if (query.category) whereConditions.push(eq(content.category, query.category));
const sortField = query.sort.startsWith('-') ? query.sort.slice(1) : query.sort;
const sortDirection = query.sort.startsWith('-') ? desc : asc;
const [items, [{ count }]] = await Promise.all([
db
.select()
.from(content)
.where(whereConditions.length > 0 ? and(...whereConditions) : undefined)
.orderBy(sortDirection(content[sortField as keyof typeof content] as any))
.limit(query.pageSize)
.offset((query.page - 1) * query.pageSize),
db
.select({ count: sql<number>`count(*)` })
.from(content)
.where(whereConditions.length > 0 ? and(...whereConditions) : undefined),
]);
return NextResponse.json({
success: true,
data: items,
meta: {
total: count,
page: query.page,
pageSize: query.pageSize,
totalPages: Math.ceil(count / query.pageSize),
},
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'INVALID_QUERY', message: error.message } },
{ status: 400 }
);
}
}
export async function POST(request: NextRequest) {
try {
const permission = await checkPermission('content', 'create');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限创建内容' } },
{ status: 403 }
);
}
const body = await request.json();
const validatedData = ContentCreateSchema.parse(body);
const newContent = await db
.insert(content)
.values({
...validatedData,
id: nanoid(),
authorId: permission.userId!,
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: validatedData.status === 'published' ? new Date() : undefined,
})
.returning();
return NextResponse.json({ success: true, data: newContent[0] }, { status: 201 });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'CREATE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
步骤 3: 创建单个内容 API
创建 src/app/api/v1/content/[id]/route.ts 文件,内容如下:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { content } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { ContentUpdateSchema } from '@/lib/validators/content';
import { checkPermission } from '@/lib/auth/check-permission';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const item = await db
.select()
.from(content)
.where(eq(content.id, params.id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '内容不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: item[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'FETCH_FAILED', message: error.message } },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const permission = await checkPermission('content', 'update');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限更新内容' } },
{ status: 403 }
);
}
const body = await request.json();
const validatedData = ContentUpdateSchema.parse(body);
const updated = await db
.update(content)
.set({
...validatedData,
updatedAt: new Date(),
publishedAt: validatedData.status === 'published' ? new Date() : undefined,
})
.where(eq(content.id, params.id))
.returning();
if (updated.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '内容不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: updated[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'UPDATE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const permission = await checkPermission('content', 'delete');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限删除内容' } },
{ status: 403 }
);
}
const deleted = await db.delete(content).where(eq(content.id, params.id)).returning();
if (deleted.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '内容不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: deleted[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'DELETE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
步骤 4: 提交
运行命令:
git add src/lib/validators/ src/app/api/v1/content/
git commit -m "feat: 创建内容 CRUD API
- GET /api/v1/content - 获取内容列表
- POST /api/v1/content - 创建内容
- GET /api/v1/content/:id - 获取单个内容
- PUT /api/v1/content/:id - 更新内容
- DELETE /api/v1/content/:id - 删除内容
- 添加 Zod 验证
- 添加权限检查"
Task 10: 创建配置管理 API
文件:
- 创建:
src/app/api/v1/config/route.ts - 创建:
src/app/api/v1/config/[key]/route.ts
步骤 1: 创建配置列表 API
创建 src/app/api/v1/config/route.ts 文件,内容如下:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
export async function GET() {
try {
const configs = await db.select().from(siteConfig);
return NextResponse.json({
success: true,
data: configs,
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'FETCH_FAILED', message: error.message } },
{ status: 500 }
);
}
}
步骤 2: 创建单个配置 API
创建 src/app/api/v1/config/[key]/route.ts 文件,内容如下:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { checkPermission } from '@/lib/auth/check-permission';
import { z } from 'zod';
const ConfigUpdateSchema = z.object({
value: z.any(),
});
export async function GET(
request: NextRequest,
{ params }: { params: { key: string } }
) {
try {
const config = await db
.select()
.from(siteConfig)
.where(eq(siteConfig.key, params.key))
.limit(1);
if (config.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '配置不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: config[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'FETCH_FAILED', message: error.message } },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { key: string } }
) {
try {
const permission = await checkPermission('config', 'update');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限更新配置' } },
{ status: 403 }
);
}
const body = await request.json();
const { value } = ConfigUpdateSchema.parse(body);
const updated = await db
.update(siteConfig)
.set({
value,
updatedAt: new Date(),
updatedBy: permission.userId,
})
.where(eq(siteConfig.key, params.key))
.returning();
if (updated.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '配置不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: updated[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'UPDATE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
步骤 3: 提交
运行命令:
git add src/app/api/v1/config/
git commit -m "feat: 创建配置管理 API
- GET /api/v1/config - 获取所有配置
- GET /api/v1/config/:key - 获取单个配置
- PUT /api/v1/config/:key - 更新配置
- 添加权限检查"
阶段三:管理后台界面(预计 2 天)
Task 11: 创建管理后台布局
文件:
- 创建:
src/components/admin/sidebar.tsx - 创建:
src/components/admin/header.tsx - 创建:
src/app/admin/layout.tsx
步骤 1: 创建侧边栏组件
创建 src/components/admin/sidebar.tsx 文件,内容如下:
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
FileText,
Package,
Briefcase,
Trophy,
Settings,
Users,
History,
} from 'lucide-react';
const menuItems = [
{ icon: LayoutDashboard, label: '仪表盘', href: '/admin' },
{ icon: FileText, label: '新闻管理', href: '/admin/content/news' },
{ icon: Package, label: '产品管理', href: '/admin/content/products' },
{ icon: Briefcase, label: '服务管理', href: '/admin/content/services' },
{ icon: Trophy, label: '案例管理', href: '/admin/content/cases' },
{ icon: Settings, label: '配置中心', href: '/admin/config' },
{ icon: Users, label: '用户管理', href: '/admin/users' },
{ icon: History, label: '操作日志', href: '/admin/logs' },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 bg-white border-r border-gray-200 min-h-screen">
<div className="p-6">
<h1 className="text-xl font-bold text-gray-900">管理后台</h1>
</div>
<nav className="px-4 space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
isActive ? 'bg-[#C41E3A] text-white' : 'text-gray-700 hover:bg-gray-100'
)}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}
步骤 2: 创建顶部栏组件
创建 src/components/admin/header.tsx 文件,内容如下:
'use client';
import { useSession, signOut } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Bell, User, LogOut } from 'lucide-react';
import Link from 'next/link';
export function Header() {
const { data: session } = useSession();
return (
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<Link href="/" className="text-sm text-gray-600 hover:text-gray-900">
查看网站
</Link>
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon">
<Bell className="w-5 h-5" />
</Button>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-gray-600" />
</div>
<div className="text-sm">
<p className="font-medium">{session?.user?.name}</p>
<p className="text-xs text-gray-500">{session?.user?.role}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => signOut({ callbackUrl: '/admin/login' })}
>
<LogOut className="w-4 h-4" />
</Button>
</div>
</div>
</header>
);
}
步骤 3: 创建管理后台布局
创建 src/app/admin/layout.tsx 文件,内容如下:
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { Sidebar } from '@/components/admin/sidebar';
import { Header } from '@/components/admin/header';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/admin/login');
}
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}
步骤 4: 提交
运行命令:
git add src/components/admin/ src/app/admin/layout.tsx
git commit -m "feat: 创建管理后台布局
- 侧边栏导航
- 顶部栏(用户信息、登出)
- 权限保护(未登录重定向)"
Task 12: 创建登录页面
文件:
- 创建:
src/app/admin/login/page.tsx
步骤 1: 创建登录页面
创建 src/app/admin/login/page.tsx 文件,内容如下:
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Mail, Lock, ArrowRight } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('邮箱或密码错误');
} else {
router.push('/admin');
router.refresh();
}
} catch (err) {
setError('登录失败,请重试');
} finally {
setIsLoading(false);
}
};
const handleMagicLink = async () => {
if (!email) {
setError('请输入邮箱地址');
return;
}
setIsLoading(true);
try {
await signIn('email', { email, redirect: false });
setError('登录链接已发送到您的邮箱');
} catch (err) {
setError('发送失败,请重试');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-linear-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">管理后台登录</CardTitle>
<CardDescription>请使用邮箱密码或 Magic Link 登录</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">邮箱</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="email"
placeholder="admin@novalon.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">密码</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">{error}</p>
)}
<Button
type="submit"
className="w-full bg-[#C41E3A] hover:bg-[#A01830]"
disabled={isLoading}
>
{isLoading ? '登录中...' : '登录'}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">或</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleMagicLink}
disabled={isLoading}
>
发送 Magic Link
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
步骤 2: 提交
运行命令:
git add src/app/admin/login/
git commit -m "feat: 创建管理后台登录页面
- 邮箱密码登录
- Magic Link 登录
- 错误提示
- 加载状态"
Task 13: 创建仪表盘页面
文件:
- 创建:
src/components/admin/stats-card.tsx - 创建:
src/app/admin/page.tsx
步骤 1: 创建统计卡片组件
创建 src/components/admin/stats-card.tsx 文件,内容如下:
import { Card, CardContent } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
interface StatsCardProps {
title: string;
value: string | number;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
}
export function StatsCard({ title, value, icon: Icon, trend }: StatsCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-3xl font-bold text-gray-900 mt-2">{value}</p>
{trend && (
<p
className={`text-sm mt-2 ${
trend.isPositive ? 'text-green-600' : 'text-red-600'
}`}
>
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%
</p>
)}
</div>
<div className="w-12 h-12 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center">
<Icon className="w-6 h-6 text-[#C41E3A]" />
</div>
</div>
</CardContent>
</Card>
);
}
步骤 2: 创建仪表盘页面
创建 src/app/admin/page.tsx 文件,内容如下:
import { db } from '@/db';
import { content, users } from '@/db/schema';
import { sql } from 'drizzle-orm';
import { StatsCard } from '@/components/admin/stats-card';
import { FileText, Package, Briefcase, Users } from 'lucide-react';
export default async function DashboardPage() {
const [newsCount, productsCount, servicesCount, usersCount] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(content).where(sql`type = 'news'`),
db.select({ count: sql<number>`count(*)` }).from(content).where(sql`type = 'product'`),
db.select({ count: sql<number>`count(*)` }).from(content).where(sql`type = 'service'`),
db.select({ count: sql<number>`count(*)` }).from(users),
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">仪表盘</h1>
<p className="text-gray-600 mt-1">欢迎回来,查看网站运营数据</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard title="新闻数量" value={newsCount[0].count} icon={FileText} />
<StatsCard title="产品数量" value={productsCount[0].count} icon={Package} />
<StatsCard title="服务数量" value={servicesCount[0].count} icon={Briefcase} />
<StatsCard title="用户数量" value={usersCount[0].count} icon={Users} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg border p-6">
<h2 className="text-lg font-semibold mb-4">最近更新</h2>
<p className="text-gray-500 text-sm">暂无最近更新</p>
</div>
<div className="bg-white rounded-lg border p-6">
<h2 className="text-lg font-semibold mb-4">快捷操作</h2>
<div className="grid grid-cols-2 gap-4">
<a
href="/admin/content/news/create"
className="p-4 border rounded-lg hover:bg-gray-50 transition-colors"
>
<FileText className="w-6 h-6 text-[#C41E3A] mb-2" />
<p className="font-medium">发布新闻</p>
</a>
<a
href="/admin/content/products/create"
className="p-4 border rounded-lg hover:bg-gray-50 transition-colors"
>
<Package className="w-6 h-6 text-[#C41E3A] mb-2" />
<p className="font-medium">添加产品</p>
</a>
</div>
</div>
</div>
</div>
);
}
步骤 3: 提交
运行命令:
git add src/components/admin/stats-card.tsx src/app/admin/page.tsx
git commit -m "feat: 创建管理后台仪表盘页面
- 统计卡片(新闻、产品、服务、用户数量)
- 最近更新区域
- 快捷操作区域"
完成清单
功能完成标准
- 用户可以使用邮箱密码或 Magic Link 登录
- 管理员可以创建、编辑、删除内容
- 配置更新实时生效
- 权限控制正常工作
- 数据库迁移成功
- 种子数据创建成功
代码质量标准
- TypeScript 类型覆盖率 ≥ 90%
- ESLint 规则通过率 100%
- 所有文件提交到 Git
配置完成标准
- Resend API Key 配置正确
- 本地文件上传目录创建
- 域名配置正确
后续任务
完成上述 13 个任务后,还需要:
- 内容管理界面(新闻列表、创建、编辑页面)
- 富文本编辑器集成
- 文件上传功能
- 配置中心界面
- 用户管理界面
- E2E 测试
- 性能优化
- 部署上线
计划完成! 🎉
保存位置: docs/plans/2026-03-08-configurable-cms-execution.md
预计完成时间: 6-7 天
下一步: 使用 executing-plans skill 开始执行任务