Files
novalon-website/docs/plans/2026-03-08-configurable-cms-execution.md
T
张翔 6d92024b63 feat: 修复测试套件问题并添加Woodpecker CI配置
- 修复API测试认证问题:创建全局认证设置,更新Playwright配置
- 优化回归测试稳定性:增加超时时间到15秒,修复定位器
- 创建Woodpecker CI工作流:CI、部署和质量门禁配置
- 添加Jest配置和测试脚本
- 移除登录页面的默认账号密码显示(安全问题修复)
2026-03-09 10:26:02 +08:00

1781 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 可配置化 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: 安装数据库相关依赖**
运行命令:
```bash
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
```
**步骤 2: 安装认证相关依赖**
运行命令:
```bash
npm install next-auth@beta @auth/drizzle-adapter
npm install bcryptjs
npm install -D @types/bcryptjs
```
**步骤 3: 安装富文本编辑器**
运行命令:
```bash
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/pm
```
**步骤 4: 安装邮件和工具库**
运行命令:
```bash
npm install resend nanoid date-fns
npm install -D @types/nanoid
```
**步骤 5: 验证安装**
运行命令:
```bash
npm list drizzle-orm next-auth @tiptap/react resend
```
预期输出:所有依赖包版本正确显示
**步骤 6: 提交**
运行命令:
```bash
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` 文件,内容如下:
```env
# 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: 提交**
运行命令:
```bash
git add .gitignore
git commit -m "chore: 配置环境变量和 .gitignore
- 添加数据库、认证、邮件配置
- 配置 Resend API Key
- 配置本地文件上传目录
- 忽略敏感文件"
```
---
### Task 3: 配置数据库连接
**文件:**
- 创建: `src/db/index.ts`
**步骤 1: 创建数据库连接文件**
创建 `src/db/index.ts` 文件,内容如下:
```typescript
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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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` 中添加:
```json
{
"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: 提交**
运行命令:
```bash
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: 生成迁移文件**
运行命令:
```bash
npm run db:generate
```
预期输出:在 `drizzle/` 目录生成 SQL 迁移文件
**步骤 2: 执行迁移**
运行命令:
```bash
npm run db:push
```
预期输出:创建 `data.db` 文件,包含所有表
**步骤 3: 验证表结构**
运行命令:
```bash
sqlite3 data.db ".tables"
```
预期输出:显示所有表名 (users, content, content_versions, site_config, audit_logs)
**步骤 4: 提交**
运行命令:
```bash
git add drizzle/
git commit -m "feat: 生成数据库迁移文件"
```
---
### Task 6: 创建数据库种子数据
**文件:**
- 创建: `src/db/seed.ts`
**步骤 1: 创建种子数据脚本**
创建 `src/db/seed.ts` 文件,内容如下:
```typescript
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: 执行种子数据**
运行命令:
```bash
npm run db:seed
```
预期输出:创建管理员用户和默认配置
**步骤 3: 验证数据**
运行命令:
```bash
sqlite3 data.db "SELECT email, role FROM users"
```
预期输出:显示管理员邮箱和角色
**步骤 4: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
'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` 文件,内容如下:
```typescript
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
```typescript
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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
'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` 文件,内容如下:
```typescript
'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` 文件,内容如下:
```typescript
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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
'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: 提交**
运行命令:
```bash
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` 文件,内容如下:
```typescript
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` 文件,内容如下:
```typescript
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: 提交**
运行命令:
```bash
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 个任务后,还需要:
1. **内容管理界面**(新闻列表、创建、编辑页面)
2. **富文本编辑器集成**
3. **文件上传功能**
4. **配置中心界面**
5. **用户管理界面**
6. **E2E 测试**
7. **性能优化**
8. **部署上线**
---
**计划完成!** 🎉
**保存位置:** `docs/plans/2026-03-08-configurable-cms-execution.md`
**预计完成时间:** 6-7 天
**下一步:** 使用 `executing-plans` skill 开始执行任务