6a2c4fdae8
- 详细规划 16 个实施任务 - 包含完整的代码示例和测试用例 - 分 6 个阶段,预计 7-8 天完成
2183 lines
56 KiB
Markdown
2183 lines
56 KiB
Markdown
# 可配置化 CMS 系统实施计划
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**目标:** 为 Novalon Website 构建一个轻量级、可配置的内容管理系统,实现新闻、产品、服务、案例的动态管理和配置。
|
||
|
||
**架构:** 采用 Next.js API Routes + SQLite (libsql) + Drizzle ORM + NextAuth.js 的技术栈,实现前后端分离的 CMS 架构。管理后台使用客户端渲染(CSR),前端页面使用增量静态再生(ISR)和服务器端渲染(SSR)混合模式。
|
||
|
||
**技术栈:** Next.js 16, React 19, TypeScript, SQLite (libsql), Drizzle ORM, NextAuth.js, Tiptap, shadcn/ui, Zod, Vitest, Playwright
|
||
|
||
---
|
||
|
||
## 前置准备
|
||
|
||
### 环境要求
|
||
|
||
- Node.js 18+
|
||
- npm / pnpm
|
||
- Git
|
||
|
||
### 设计文档
|
||
|
||
参考:`docs/plans/2026-03-08-configurable-cms-design.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 nanoid date-fns
|
||
npm install -D @types/nanoid
|
||
```
|
||
|
||
**步骤 5: 验证安装**
|
||
|
||
运行: `npm list drizzle-orm next-auth @tiptap/react`
|
||
|
||
预期: 所有依赖包版本正确显示
|
||
|
||
**步骤 6: 提交**
|
||
|
||
```bash
|
||
git add package.json package-lock.json
|
||
git commit -m "chore: 添加 CMS 系统所需依赖包"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 配置数据库连接
|
||
|
||
**文件:**
|
||
- 创建: `src/db/index.ts`
|
||
- 创建: `.env.local`
|
||
- 修改: `.gitignore`
|
||
|
||
**步骤 1: 创建环境变量文件**
|
||
|
||
创建 `.env.local`:
|
||
|
||
```env
|
||
# Database
|
||
DATABASE_URL="file:./data.db"
|
||
|
||
# NextAuth
|
||
NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
|
||
NEXTAUTH_URL="http://localhost:3000"
|
||
|
||
# Admin (初始管理员账号)
|
||
ADMIN_EMAIL="admin@novalon.cn"
|
||
ADMIN_PASSWORD="admin123456"
|
||
```
|
||
|
||
**步骤 2: 更新 .gitignore**
|
||
|
||
在 `.gitignore` 中添加:
|
||
|
||
```
|
||
# Database
|
||
*.db
|
||
*.db-journal
|
||
data.db
|
||
|
||
# Environment
|
||
.env.local
|
||
.env.*.local
|
||
```
|
||
|
||
**步骤 3: 创建数据库连接文件**
|
||
|
||
创建 `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);
|
||
```
|
||
|
||
**步骤 4: 验证数据库连接**
|
||
|
||
创建临时测试文件 `src/db/test-connection.ts`:
|
||
|
||
```typescript
|
||
import { db } from './index';
|
||
|
||
async function testConnection() {
|
||
try {
|
||
await db.run('SELECT 1');
|
||
console.log('✅ Database connection successful');
|
||
} catch (error) {
|
||
console.error('❌ Database connection failed:', error);
|
||
}
|
||
}
|
||
|
||
testConnection();
|
||
```
|
||
|
||
运行: `npx tsx src/db/test-connection.ts`
|
||
|
||
预期: 输出 "✅ Database connection successful"
|
||
|
||
**步骤 5: 删除测试文件**
|
||
|
||
```bash
|
||
rm src/db/test-connection.ts
|
||
```
|
||
|
||
**步骤 6: 提交**
|
||
|
||
```bash
|
||
git add src/db/index.ts .gitignore
|
||
git commit -m "feat: 配置数据库连接"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 定义数据库 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: 验证 Schema 定义**
|
||
|
||
运行: `npm run db:generate`
|
||
|
||
预期: 在 `drizzle/` 目录生成迁移文件
|
||
|
||
**步骤 5: 提交**
|
||
|
||
```bash
|
||
git add src/db/schema.ts drizzle.config.ts package.json
|
||
git commit -m "feat: 定义数据库 Schema"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 生成并执行数据库迁移
|
||
|
||
**文件:**
|
||
- 创建: `drizzle/0000_initial_migration.sql` (自动生成)
|
||
- 创建: `data.db` (自动创建)
|
||
|
||
**步骤 1: 生成迁移文件**
|
||
|
||
运行: `npm run db:generate`
|
||
|
||
预期: 生成 SQL 迁移文件
|
||
|
||
**步骤 2: 执行迁移**
|
||
|
||
运行: `npm run db:push`
|
||
|
||
预期: 创建 `data.db` 文件,包含所有表
|
||
|
||
**步骤 3: 验证表结构**
|
||
|
||
运行: `sqlite3 data.db ".tables"`
|
||
|
||
预期: 显示所有表名 (users, content, content_versions, site_config, audit_logs)
|
||
|
||
**步骤 4: 提交**
|
||
|
||
```bash
|
||
git add drizzle/
|
||
git commit -m "feat: 生成数据库迁移文件"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 创建数据库种子数据
|
||
|
||
**文件:**
|
||
- 创建: `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('🌱 开始种子数据...');
|
||
|
||
// 创建管理员用户
|
||
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('🎉 种子数据完成!');
|
||
}
|
||
|
||
seed().catch((error) => {
|
||
console.error('❌ 种子数据失败:', error);
|
||
process.exit(1);
|
||
});
|
||
```
|
||
|
||
**步骤 2: 执行种子数据**
|
||
|
||
运行: `npm run db:seed`
|
||
|
||
预期: 创建管理员用户和默认配置
|
||
|
||
**步骤 3: 验证数据**
|
||
|
||
运行: `sqlite3 data.db "SELECT email, role FROM users"`
|
||
|
||
预期: 显示管理员邮箱和角色
|
||
|
||
**步骤 4: 提交**
|
||
|
||
```bash
|
||
git add src/db/seed.ts
|
||
git commit -m "feat: 添加数据库种子数据脚本"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 配置 NextAuth.js
|
||
|
||
**文件:**
|
||
- 创建: `src/app/api/auth/[...nextauth]/route.ts`
|
||
- 创建: `src/lib/auth.ts`
|
||
- 修改: `src/app/layout.tsx`
|
||
- 创建: `src/providers/session-provider.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 { db } from '@/db';
|
||
import { users } from '@/db/schema';
|
||
import { eq } from 'drizzle-orm';
|
||
import bcrypt from 'bcryptjs';
|
||
|
||
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: {
|
||
host: process.env.SMTP_HOST || 'smtp.example.com',
|
||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||
auth: {
|
||
user: process.env.SMTP_USER || '',
|
||
pass: process.env.SMTP_PASSWORD || '',
|
||
},
|
||
},
|
||
from: process.env.SMTP_FROM || 'noreply@novalon.cn',
|
||
}),
|
||
],
|
||
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/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>
|
||
);
|
||
}
|
||
```
|
||
|
||
**步骤 5: 创建类型定义**
|
||
|
||
创建 `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;
|
||
}
|
||
}
|
||
```
|
||
|
||
**步骤 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 认证系统"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 创建权限检查工具
|
||
|
||
**文件:**
|
||
- 创建: `src/lib/auth/permissions.ts`
|
||
- 创建: `src/lib/auth/middleware.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: 创建权限检查 Hook**
|
||
|
||
创建 `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: 创建测试**
|
||
|
||
创建 `src/lib/auth/__tests__/permissions.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { hasPermission } from '../permissions';
|
||
|
||
describe('Permissions', () => {
|
||
it('should allow admin to do everything', () => {
|
||
expect(hasPermission('admin', 'content', 'create')).toBe(true);
|
||
expect(hasPermission('admin', 'config', 'update')).toBe(true);
|
||
expect(hasPermission('admin', 'users', 'delete')).toBe(true);
|
||
});
|
||
|
||
it('should restrict editor from managing users', () => {
|
||
expect(hasPermission('editor', 'content', 'create')).toBe(true);
|
||
expect(hasPermission('editor', 'users', 'create')).toBe(false);
|
||
});
|
||
|
||
it('should restrict viewer to read-only', () => {
|
||
expect(hasPermission('viewer', 'content', 'read')).toBe(true);
|
||
expect(hasPermission('viewer', 'content', 'create')).toBe(false);
|
||
expect(hasPermission('viewer', 'config', 'update')).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
**步骤 4: 运行测试**
|
||
|
||
运行: `npm test src/lib/auth/__tests__/permissions.test.ts`
|
||
|
||
预期: 所有测试通过
|
||
|
||
**步骤 5: 提交**
|
||
|
||
```bash
|
||
git add src/lib/auth/
|
||
git commit -m "feat: 创建权限检查工具和测试"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段二:内容管理 API(预计 2 天)
|
||
|
||
### Task 8: 创建内容 CRUD API
|
||
|
||
**文件:**
|
||
- 创建: `src/app/api/v1/content/route.ts`
|
||
- 创建: `src/app/api/v1/content/[id]/route.ts`
|
||
- 创建: `src/lib/validators/content.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: 创建 API 测试**
|
||
|
||
创建 `src/app/api/v1/content/__tests__/route.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { NextRequest } from 'next/server';
|
||
import { GET, POST } from '../route';
|
||
|
||
describe('Content API', () => {
|
||
it('should return empty list initially', async () => {
|
||
const request = new NextRequest('http://localhost:3000/api/v1/content');
|
||
const response = await GET(request);
|
||
const data = await response.json();
|
||
|
||
expect(response.status).toBe(200);
|
||
expect(data.success).toBe(true);
|
||
expect(data.data).toEqual([]);
|
||
});
|
||
|
||
it('should create content with valid data', async () => {
|
||
const request = new NextRequest('http://localhost:3000/api/v1/content', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
type: 'news',
|
||
title: '测试新闻',
|
||
slug: 'test-news',
|
||
content: '这是测试内容',
|
||
}),
|
||
});
|
||
|
||
const response = await POST(request);
|
||
const data = await response.json();
|
||
|
||
expect(response.status).toBe(201);
|
||
expect(data.success).toBe(true);
|
||
expect(data.data.title).toBe('测试新闻');
|
||
});
|
||
});
|
||
```
|
||
|
||
**步骤 5: 运行测试**
|
||
|
||
运行: `npm test src/app/api/v1/content/__tests__/route.test.ts`
|
||
|
||
预期: 测试通过(可能需要 mock 数据库)
|
||
|
||
**步骤 6: 提交**
|
||
|
||
```bash
|
||
git add src/app/api/v1/content/ src/lib/validators/
|
||
git commit -m "feat: 创建内容 CRUD API"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 创建配置管理 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 { 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';
|
||
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段三:管理后台界面(预计 2 天)
|
||
|
||
### Task 10: 创建管理后台布局
|
||
|
||
**文件:**
|
||
- 创建: `src/app/admin/layout.tsx`
|
||
- 创建: `src/components/admin/sidebar.tsx`
|
||
- 创建: `src/components/admin/header.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/app/admin/layout.tsx src/components/admin/
|
||
git commit -m "feat: 创建管理后台布局"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: 创建登录页面
|
||
|
||
**文件:**
|
||
- 创建: `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-gradient-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: 测试登录功能**
|
||
|
||
运行: `npm run dev`
|
||
|
||
访问: `http://localhost:3000/admin/login`
|
||
|
||
使用: `admin@novalon.cn` / `admin123456` 登录
|
||
|
||
预期: 成功登录并跳转到管理后台
|
||
|
||
**步骤 3: 提交**
|
||
|
||
```bash
|
||
git add src/app/admin/login/
|
||
git commit -m "feat: 创建管理后台登录页面"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: 创建仪表盘页面
|
||
|
||
**文件:**
|
||
- 创建: `src/app/admin/page.tsx`
|
||
- 创建: `src/components/admin/stats-card.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/app/admin/page.tsx src/components/admin/stats-card.tsx
|
||
git commit -m "feat: 创建管理后台仪表盘页面"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段四:内容管理界面(预计 1 天)
|
||
|
||
### Task 13: 创建内容列表页面
|
||
|
||
**文件:**
|
||
- 创建: `src/app/admin/content/news/page.tsx`
|
||
- 创建: `src/components/admin/content-table.tsx`
|
||
|
||
**步骤 1: 创建内容表格组件**
|
||
|
||
创建 `src/components/admin/content-table.tsx`:
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import { Edit, Trash2, Eye } from 'lucide-react';
|
||
import { format } from 'date-fns';
|
||
import { zhCN } from 'date-fns/locale';
|
||
|
||
interface ContentItem {
|
||
id: string;
|
||
title: string;
|
||
category?: string;
|
||
status: string;
|
||
publishedAt?: Date;
|
||
createdAt: Date;
|
||
}
|
||
|
||
interface ContentTableProps {
|
||
data: ContentItem[];
|
||
type: 'news' | 'product' | 'service' | 'case';
|
||
onDelete: (id: string) => void;
|
||
}
|
||
|
||
export function ContentTable({ data, type, onDelete }: ContentTableProps) {
|
||
const getStatusBadge = (status: string) => {
|
||
const variants: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||
published: 'default',
|
||
draft: 'secondary',
|
||
archived: 'outline',
|
||
};
|
||
const labels: Record<string, string> = {
|
||
published: '已发布',
|
||
draft: '草稿',
|
||
archived: '已归档',
|
||
};
|
||
return (
|
||
<Badge variant={variants[status] || 'secondary'}>
|
||
{labels[status] || status}
|
||
</Badge>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>标题</TableHead>
|
||
<TableHead>分类</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>发布时间</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell className="font-medium">{item.title}</TableCell>
|
||
<TableCell>{item.category || '-'}</TableCell>
|
||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||
<TableCell>
|
||
{item.publishedAt
|
||
? format(new Date(item.publishedAt), 'yyyy-MM-dd', { locale: zhCN })
|
||
: '-'}
|
||
</TableCell>
|
||
<TableCell>
|
||
{format(new Date(item.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button size="sm" variant="ghost" asChild>
|
||
<Link href={`/admin/content/${type}/${item.id}/edit`}>
|
||
<Edit className="w-4 h-4" />
|
||
</Link>
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => onDelete(item.id)}
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-600" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
```
|
||
|
||
**步骤 2: 创建新闻列表页面**
|
||
|
||
创建 `src/app/admin/content/news/page.tsx`:
|
||
|
||
```typescript
|
||
import { db } from '@/db';
|
||
import { content } from '@/db/schema';
|
||
import { eq, desc } from 'drizzle-orm';
|
||
import { Button } from '@/components/ui/button';
|
||
import { ContentTable } from '@/components/admin/content-table';
|
||
import { Plus } from 'lucide-react';
|
||
import Link from 'next/link';
|
||
|
||
export default async function NewsListPage() {
|
||
const news = await db.select()
|
||
.from(content)
|
||
.where(eq(content.type, 'news'))
|
||
.orderBy(desc(content.createdAt));
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">新闻管理</h1>
|
||
<p className="text-gray-600 mt-1">管理公司新闻、产品发布等内容</p>
|
||
</div>
|
||
<Button asChild className="bg-[#C41E3A] hover:bg-[#A01830]">
|
||
<Link href="/admin/content/news/create">
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
发布新闻
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-lg border">
|
||
<ContentTable
|
||
data={news}
|
||
type="news"
|
||
onDelete={async (id) => {
|
||
'use server';
|
||
await db.delete(content).where(eq(content.id, id));
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**步骤 3: 提交**
|
||
|
||
```bash
|
||
git add src/app/admin/content/news/ src/components/admin/content-table.tsx
|
||
git commit -m "feat: 创建内容列表页面"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段五:测试和部署(预计 1 天)
|
||
|
||
### Task 14: 编写 E2E 测试
|
||
|
||
**文件:**
|
||
- 创建: `e2e/tests/admin/login.spec.ts`
|
||
- 创建: `e2e/tests/admin/content.spec.ts`
|
||
|
||
**步骤 1: 创建登录测试**
|
||
|
||
创建 `e2e/tests/admin/login.spec.ts`:
|
||
|
||
```typescript
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('管理后台登录', () => {
|
||
test('应该显示登录页面', async ({ page }) => {
|
||
await page.goto('/admin/login');
|
||
await expect(page.locator('h2')).toContainText('管理后台登录');
|
||
});
|
||
|
||
test('应该成功登录', async ({ page }) => {
|
||
await page.goto('/admin/login');
|
||
|
||
await page.fill('input[type="email"]', 'admin@novalon.cn');
|
||
await page.fill('input[type="password"]', 'admin123456');
|
||
await page.click('button[type="submit"]');
|
||
|
||
await expect(page).toHaveURL('/admin');
|
||
await expect(page.locator('h1')).toContainText('仪表盘');
|
||
});
|
||
|
||
test('应该显示错误提示', async ({ page }) => {
|
||
await page.goto('/admin/login');
|
||
|
||
await page.fill('input[type="email"]', 'wrong@example.com');
|
||
await page.fill('input[type="password"]', 'wrongpassword');
|
||
await page.click('button[type="submit"]');
|
||
|
||
await expect(page.locator('text=邮箱或密码错误')).toBeVisible();
|
||
});
|
||
});
|
||
```
|
||
|
||
**步骤 2: 创建内容管理测试**
|
||
|
||
创建 `e2e/tests/admin/content.spec.ts`:
|
||
|
||
```typescript
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('内容管理', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/admin/login');
|
||
await page.fill('input[type="email"]', 'admin@novalon.cn');
|
||
await page.fill('input[type="password"]', 'admin123456');
|
||
await page.click('button[type="submit"]');
|
||
await expect(page).toHaveURL('/admin');
|
||
});
|
||
|
||
test('应该显示新闻列表', async ({ page }) => {
|
||
await page.click('text=新闻管理');
|
||
await expect(page).toHaveURL('/admin/content/news');
|
||
await expect(page.locator('h1')).toContainText('新闻管理');
|
||
});
|
||
|
||
test('应该创建新闻', async ({ page }) => {
|
||
await page.click('text=新闻管理');
|
||
await page.click('text=发布新闻');
|
||
|
||
await page.fill('input[name="title"]', '测试新闻标题');
|
||
await page.fill('input[name="slug"]', 'test-news-slug');
|
||
await page.fill('textarea[name="excerpt"]', '这是测试新闻摘要');
|
||
await page.fill('[name="content"]', '这是测试新闻内容');
|
||
await page.click('button[type="submit"]');
|
||
|
||
await expect(page.locator('text=创建成功')).toBeVisible();
|
||
});
|
||
});
|
||
```
|
||
|
||
**步骤 3: 运行测试**
|
||
|
||
运行: `cd e2e && npm test tests/admin/`
|
||
|
||
预期: 所有测试通过
|
||
|
||
**步骤 4: 提交**
|
||
|
||
```bash
|
||
git add e2e/tests/admin/
|
||
git commit -m "test: 添加管理后台 E2E 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: 性能优化和安全检查
|
||
|
||
**文件:**
|
||
- 修改: `src/app/api/v1/content/route.ts`
|
||
- 创建: `src/middleware.ts`
|
||
|
||
**步骤 1: 添加 API 速率限制**
|
||
|
||
创建 `src/middleware.ts`:
|
||
|
||
```typescript
|
||
import { NextResponse } from 'next/server';
|
||
import type { NextRequest } from 'next/server';
|
||
|
||
const rateLimit = new Map<string, { count: number; resetTime: number }>();
|
||
|
||
export function middleware(request: NextRequest) {
|
||
// 只对 API 路由进行速率限制
|
||
if (request.nextUrl.pathname.startsWith('/api/')) {
|
||
const ip = request.ip || 'unknown';
|
||
const now = Date.now();
|
||
const windowMs = 60 * 1000; // 1 分钟
|
||
const maxRequests = 100; // 每分钟最多 100 次请求
|
||
|
||
const userLimit = rateLimit.get(ip);
|
||
|
||
if (userLimit) {
|
||
if (now > userLimit.resetTime) {
|
||
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
|
||
} else if (userLimit.count >= maxRequests) {
|
||
return NextResponse.json(
|
||
{ success: false, error: { code: 'RATE_LIMIT_EXCEEDED', message: '请求过于频繁' } },
|
||
{ status: 429 }
|
||
);
|
||
} else {
|
||
userLimit.count++;
|
||
}
|
||
} else {
|
||
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
|
||
}
|
||
}
|
||
|
||
return NextResponse.next();
|
||
}
|
||
|
||
export const config = {
|
||
matcher: '/api/:path*',
|
||
};
|
||
```
|
||
|
||
**步骤 2: 添加安全头**
|
||
|
||
修改 `next.config.ts`, 在 `headers` 中添加:
|
||
|
||
```typescript
|
||
{
|
||
key: 'Content-Security-Policy',
|
||
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;",
|
||
},
|
||
{
|
||
key: 'X-Content-Type-Options',
|
||
value: 'nosniff',
|
||
},
|
||
{
|
||
key: 'X-Frame-Options',
|
||
value: 'DENY',
|
||
},
|
||
{
|
||
key: 'X-XSS-Protection',
|
||
value: '1; mode=block',
|
||
},
|
||
```
|
||
|
||
**步骤 3: 提交**
|
||
|
||
```bash
|
||
git add src/middleware.ts next.config.ts
|
||
git commit -m "feat: 添加 API 速率限制和安全头"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段六:文档和部署
|
||
|
||
### Task 16: 更新项目文档
|
||
|
||
**文件:**
|
||
- 修改: `README.md`
|
||
- 创建: `docs/cms-usage.md`
|
||
|
||
**步骤 1: 更新 README**
|
||
|
||
在 `README.md` 中添加 CMS 相关内容:
|
||
|
||
```markdown
|
||
## CMS 管理后台
|
||
|
||
### 访问地址
|
||
|
||
- 管理后台:http://localhost:3000/admin
|
||
- 默认账号:admin@novalon.cn
|
||
- 默认密码:admin123456
|
||
|
||
### 功能模块
|
||
|
||
- **内容管理**:新闻、产品、服务、案例的增删改查
|
||
- **配置中心**:功能开关、样式配置、SEO 设置
|
||
- **用户管理**:管理员、编辑、查看者角色管理
|
||
- **操作日志**:审计追踪、安全监控
|
||
|
||
### 数据库管理
|
||
|
||
\`\`\`bash
|
||
# 生成迁移
|
||
npm run db:generate
|
||
|
||
# 执行迁移
|
||
npm run db:migrate
|
||
|
||
# 查看数据库
|
||
npm run db:studio
|
||
|
||
# 初始化数据
|
||
npm run db:seed
|
||
\`\`\`
|
||
```
|
||
|
||
**步骤 2: 创建使用文档**
|
||
|
||
创建 `docs/cms-usage.md`:
|
||
|
||
```markdown
|
||
# CMS 使用手册
|
||
|
||
## 1. 登录管理后台
|
||
|
||
访问 `/admin/login`,使用邮箱密码或 Magic Link 登录。
|
||
|
||
## 2. 内容管理
|
||
|
||
### 2.1 发布新闻
|
||
|
||
1. 点击左侧菜单「新闻管理」
|
||
2. 点击右上角「发布新闻」按钮
|
||
3. 填写标题、摘要、内容等信息
|
||
4. 选择分类和标签
|
||
5. 点击「发布」按钮
|
||
|
||
### 2.2 编辑产品
|
||
|
||
1. 点击左侧菜单「产品管理」
|
||
2. 找到需要编辑的产品,点击「编辑」按钮
|
||
3. 修改内容后点击「保存」
|
||
|
||
## 3. 配置管理
|
||
|
||
### 3.1 功能开关
|
||
|
||
在「配置中心」可以启用或禁用各功能模块。
|
||
|
||
### 3.2 SEO 配置
|
||
|
||
配置网站标题、描述、关键词等 SEO 信息。
|
||
|
||
## 4. 用户管理
|
||
|
||
管理员可以创建、编辑、删除用户,并分配角色权限。
|
||
|
||
## 5. 操作日志
|
||
|
||
查看所有操作记录,包括创建、更新、删除等。
|
||
```
|
||
|
||
**步骤 3: 提交**
|
||
|
||
```bash
|
||
git add README.md docs/cms-usage.md
|
||
git commit -m "docs: 更新 CMS 使用文档"
|
||
```
|
||
|
||
---
|
||
|
||
## 完成清单
|
||
|
||
### 功能完成标准
|
||
|
||
- [ ] 用户可以使用邮箱密码或 Magic Link 登录
|
||
- [ ] 管理员可以创建、编辑、删除内容
|
||
- [ ] 配置更新实时生效
|
||
- [ ] 权限控制正常工作
|
||
- [ ] E2E 测试全部通过
|
||
- [ ] 性能指标达标(响应时间 < 500ms)
|
||
- [ ] 安全检查通过
|
||
|
||
### 代码质量标准
|
||
|
||
- [ ] TypeScript 类型覆盖率 ≥ 90%
|
||
- [ ] ESLint 规则通过率 100%
|
||
- [ ] 单元测试覆盖率 ≥ 80%
|
||
- [ ] E2E 测试覆盖率 ≥ 60%
|
||
|
||
### 文档完成标准
|
||
|
||
- [ ] README 更新完整
|
||
- [ ] API 文档完善
|
||
- [ ] 使用手册清晰
|
||
- [ ] 部署文档详细
|
||
|
||
---
|
||
|
||
## 后续优化建议
|
||
|
||
1. **性能优化**
|
||
- 添加 Redis 缓存层
|
||
- 实现图片 CDN 加速
|
||
- 优化数据库查询
|
||
|
||
2. **功能增强**
|
||
- 实现内容定时发布
|
||
- 添加内容审核工作流
|
||
- 支持多语言内容
|
||
|
||
3. **安全加固**
|
||
- 实现双因素认证
|
||
- 添加 IP 白名单
|
||
- 完善审计日志
|
||
|
||
4. **运维监控**
|
||
- 集成 Sentry 错误监控
|
||
- 添加性能监控
|
||
- 实现自动备份
|
||
|
||
---
|
||
|
||
**计划完成!** 🎉
|
||
|
||
**保存位置:** `docs/plans/2026-03-08-configurable-cms-implementation.md`
|