552 lines
13 KiB
Markdown
552 lines
13 KiB
Markdown
# 🔌 后端对接集成文档 Part 2 (API Integration Spec - Extended)
|
||
|
||
**延续文档**: `apidev_part1.md`
|
||
**覆盖功能**: 注册、文章更新/删除、认证管理、草稿流程
|
||
|
||
---
|
||
|
||
## 1. 补充数据模型 (Extended TypeScript Interfaces)
|
||
|
||
在 `src/types/api.ts` 中追加以下接口:
|
||
|
||
```typescript
|
||
// 1. 注册模型 (Registration)
|
||
export interface RegisterRequest {
|
||
email: string;
|
||
password: string; // 最小 8 字符
|
||
}
|
||
|
||
export interface RegisterResponse {
|
||
message: string;
|
||
user: {
|
||
id: string;
|
||
email: string;
|
||
};
|
||
}
|
||
|
||
// 2. 文章更新模型 (Post Update)
|
||
export interface UpdatePostRequest {
|
||
title?: string;
|
||
content?: string;
|
||
published?: boolean;
|
||
}
|
||
|
||
// 3. 删除响应 (Delete Response)
|
||
export interface DeleteResponse {
|
||
message: string;
|
||
deleted_at?: string; // ISO 8601 - 软删除时间戳
|
||
}
|
||
|
||
// 4. 分页响应包装 (Paginated Response)
|
||
export interface PaginatedResponse<T> {
|
||
data: T[];
|
||
pagination: {
|
||
page: number;
|
||
limit: number;
|
||
total: number;
|
||
total_pages: number;
|
||
};
|
||
}
|
||
|
||
// 5. API 错误模型 (API Error)
|
||
export interface ApiError {
|
||
status: number;
|
||
message: string;
|
||
field?: string; // 验证错误时标识具体字段
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. API 客户端扩展 (Extended Service Layer)
|
||
|
||
在 `src/lib/api.ts` 中追加以下方法:
|
||
|
||
```typescript
|
||
export const api = {
|
||
// === 原有方法保持不变 ===
|
||
|
||
// Auth - 新增注册
|
||
register: (data: RegisterRequest) => request<RegisterResponse>('/auth/register', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
}),
|
||
|
||
// Auth - 登出 (清理本地 Token)
|
||
logout: () => {
|
||
localStorage.removeItem('auth_token');
|
||
localStorage.removeItem('user_info');
|
||
// 无需调用后端,JWT 无状态
|
||
},
|
||
|
||
// Posts - 更新文章
|
||
updatePost: (id: string, data: UpdatePostRequest) => request<Post>(`/posts/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data),
|
||
}),
|
||
|
||
// Posts - 删除文章 (软删除)
|
||
deletePost: (id: string) => request<DeleteResponse>(`/posts/${id}`, {
|
||
method: 'DELETE',
|
||
}),
|
||
|
||
// Posts - 发布/取消发布 (便捷方法)
|
||
publishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ published: true }),
|
||
}),
|
||
|
||
unpublishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ published: false }),
|
||
}),
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 功能模块集成策略 (续)
|
||
|
||
### 3.4 用户注册 (Registration Flow)
|
||
|
||
**极简注册表单应与登录共享视觉语言。**
|
||
|
||
* **API**: `POST /api/auth/register`
|
||
* **必填字段**: `email`, `password`
|
||
* **验证规则**:
|
||
* Email: 有效邮箱格式
|
||
* Password: 最少 8 字符
|
||
|
||
**UI 交互映射:**
|
||
|
||
| 用户行为 | 前端反馈 |
|
||
|---------|---------|
|
||
| 输入密码 < 8 字符 | 密码框下边框变灰 `border-neutral-300`,显示字符计数 `5/8` |
|
||
| 提交时密码过短 | 边框变红 + 微震动,显示 "Min 8 characters" |
|
||
| Email 已存在 (409) | Email 框下方显示 "Already registered" |
|
||
| 注册成功 | 按钮文字变为 "Created",1.5s 后跳转登录页 |
|
||
|
||
**组件实现建议:**
|
||
|
||
```typescript
|
||
// src/components/RegisterForm.tsx
|
||
const [charCount, setCharCount] = useState(0);
|
||
|
||
<input
|
||
type="password"
|
||
onChange={(e) => setCharCount(e.target.value.length)}
|
||
className={cn(
|
||
"border-b-2 transition-colors",
|
||
charCount < 8 && charCount > 0 ? "border-neutral-300" : "border-black"
|
||
)}
|
||
/>
|
||
{charCount > 0 && charCount < 8 && (
|
||
<span className="text-xs text-neutral-400 mt-1">{charCount}/8</span>
|
||
)}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.5 文章编辑与更新 (Edit Post Flow)
|
||
|
||
**编辑器应复用发布页面,但预填充现有数据。**
|
||
|
||
* **API**: `PUT /api/posts/{id}`
|
||
* **可更新字段**: `title`, `content`, `published`
|
||
* **权限**: 需要 Bearer Token + 文章所有权
|
||
|
||
**页面路由建议:**
|
||
|
||
```
|
||
/posts/[id]/edit -> 编辑页面
|
||
```
|
||
|
||
**数据加载流程:**
|
||
|
||
```typescript
|
||
// src/app/posts/[id]/edit/page.tsx
|
||
useEffect(() => {
|
||
const loadPost = async () => {
|
||
try {
|
||
const post = await api.getPostById(id);
|
||
setTitle(post.title);
|
||
setContent(post.content);
|
||
setPublished(post.published);
|
||
} catch (err) {
|
||
if (err.status === 404) router.push('/404');
|
||
if (err.status === 401) router.push('/login');
|
||
}
|
||
};
|
||
loadPost();
|
||
}, [id]);
|
||
```
|
||
|
||
**保存策略:**
|
||
|
||
| 策略 | 实现方式 |
|
||
|-----|---------|
|
||
| 手动保存 | 点击 "Save" 按钮触发 `PUT` |
|
||
| 自动保存 (可选) | `debounce` 300ms 后自动调用 `updatePost`,右上角显示 "Saving..." → "Saved" |
|
||
|
||
**极简自动保存指示器:**
|
||
|
||
```typescript
|
||
// 不要用 spinner,用文字状态
|
||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||
|
||
{saveStatus === 'saving' && <span className="text-neutral-400 text-xs">Saving...</span>}
|
||
{saveStatus === 'saved' && <span className="text-neutral-600 text-xs">Saved</span>}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.6 文章删除 (Delete Post Flow)
|
||
|
||
**删除是破坏性操作,但极简设计不使用模态确认框。**
|
||
|
||
* **API**: `DELETE /api/posts/{id}`
|
||
* **行为**: 软删除 (数据库标记 `deleted_at`)
|
||
* **权限**: 需要 Bearer Token + 文章所有权
|
||
|
||
**极简确认模式 (Inline Confirmation):**
|
||
|
||
```typescript
|
||
// 第一次点击:文字变化
|
||
// 第二次点击:执行删除
|
||
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
|
||
<button
|
||
onClick={() => {
|
||
if (confirmDelete) {
|
||
handleDelete();
|
||
} else {
|
||
setConfirmDelete(true);
|
||
setTimeout(() => setConfirmDelete(false), 3000); // 3秒后重置
|
||
}
|
||
}}
|
||
className={cn(
|
||
"text-sm transition-colors",
|
||
confirmDelete ? "text-red-600" : "text-neutral-400"
|
||
)}
|
||
>
|
||
{confirmDelete ? "Confirm delete" : "Delete"}
|
||
</button>
|
||
```
|
||
|
||
**删除后行为:**
|
||
|
||
| 场景 | 反馈 |
|
||
|-----|-----|
|
||
| 在列表页删除 | 该行淡出 (`opacity-0 transition-opacity`),然后移除 |
|
||
| 在详情页删除 | 显示 "Deleted",2秒后跳转首页 |
|
||
|
||
---
|
||
|
||
### 3.7 草稿与发布状态管理 (Draft/Published Toggle)
|
||
|
||
**文章有两种状态:草稿 (draft) 和已发布 (published)。**
|
||
|
||
* **草稿**: `published: false` — 仅作者可见
|
||
* **已发布**: `published: true` — 公开可见
|
||
|
||
**状态切换 UI:**
|
||
|
||
```typescript
|
||
// 极简 Toggle:不用开关组件,用文字按钮
|
||
<button
|
||
onClick={() => api.updatePost(id, { published: !isPublished })}
|
||
className="text-sm text-neutral-500 hover:text-black transition-colors"
|
||
>
|
||
{isPublished ? "Unpublish" : "Publish"}
|
||
</button>
|
||
```
|
||
|
||
**列表页草稿标识:**
|
||
|
||
```typescript
|
||
// 草稿文章标题后显示灰色 (Draft) 标记
|
||
<h2 className="text-lg font-medium">
|
||
{post.title}
|
||
{!post.published && (
|
||
<span className="text-neutral-400 text-sm ml-2">(Draft)</span>
|
||
)}
|
||
</h2>
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 认证状态管理 (Auth State Management)
|
||
|
||
### 4.1 Token 存储策略
|
||
|
||
```typescript
|
||
// src/lib/auth.ts
|
||
|
||
export const auth = {
|
||
// 存储 Token
|
||
setToken: (token: string) => {
|
||
localStorage.setItem('auth_token', token);
|
||
},
|
||
|
||
// 获取 Token
|
||
getToken: () => localStorage.getItem('auth_token'),
|
||
|
||
// 检查是否已登录
|
||
isAuthenticated: () => !!localStorage.getItem('auth_token'),
|
||
|
||
// 清除认证信息
|
||
clear: () => {
|
||
localStorage.removeItem('auth_token');
|
||
localStorage.removeItem('user_info');
|
||
},
|
||
|
||
// 存储用户信息
|
||
setUser: (user: { id: string; email: string }) => {
|
||
localStorage.setItem('user_info', JSON.stringify(user));
|
||
},
|
||
|
||
// 获取用户信息
|
||
getUser: () => {
|
||
const stored = localStorage.getItem('user_info');
|
||
return stored ? JSON.parse(stored) : null;
|
||
},
|
||
};
|
||
```
|
||
|
||
### 4.2 全局认证守卫 (Auth Guard)
|
||
|
||
```typescript
|
||
// src/components/AuthGuard.tsx
|
||
'use client';
|
||
|
||
import { useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { auth } from '@/lib/auth';
|
||
|
||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
if (!auth.isAuthenticated()) {
|
||
router.replace('/login');
|
||
}
|
||
}, [router]);
|
||
|
||
if (!auth.isAuthenticated()) {
|
||
return null; // 或返回骨架屏
|
||
}
|
||
|
||
return <>{children}</>;
|
||
}
|
||
|
||
// 使用方式:包裹需要认证的页面
|
||
// src/app/posts/new/page.tsx
|
||
export default function NewPostPage() {
|
||
return (
|
||
<AuthGuard>
|
||
<PostEditor />
|
||
</AuthGuard>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.3 401 全局拦截
|
||
|
||
更新 `src/lib/api.ts` 中的 `request` 函数:
|
||
|
||
```typescript
|
||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
// ... 原有代码 ...
|
||
|
||
if (!response.ok) {
|
||
// 401 时自动清除 Token 并跳转
|
||
if (response.status === 401) {
|
||
auth.clear();
|
||
// 使用 window.location 而非 router,确保完全刷新状态
|
||
window.location.href = '/login';
|
||
}
|
||
throw { status: response.status, message: response.statusText };
|
||
}
|
||
|
||
// ... 原有代码 ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 扩展状态反馈映射表 (Extended UI Feedback)
|
||
|
||
| 后端状态 | 场景 | 前端极简反馈 |
|
||
|---------|-----|-------------|
|
||
| **201 Created** | 注册成功 | 按钮文字变为 "Created",跳转登录页 |
|
||
| **409 Conflict** | Email 已存在 | Email 框下方显示 "Already registered" |
|
||
| **400 Bad Request** | 密码过短 | 显示字符计数 `5/8`,边框变红 |
|
||
| **200 OK** | 文章更新成功 | 右上角显示 "Saved" (2秒消失) |
|
||
| **200 OK** | 文章删除成功 | 当前行/页面淡出,跳转 |
|
||
| **403 Forbidden** | 无权编辑他人文章 | 显示 "Not your post",禁用编辑按钮 |
|
||
| **422 Unprocessable** | 文章内容不合法 | 对应字段边框变红 |
|
||
|
||
---
|
||
|
||
## 6. 页面路由规划 (Route Structure)
|
||
|
||
```
|
||
/ # 首页 - 文章列表
|
||
/login # 登录
|
||
/register # 注册 (新增)
|
||
/posts/[id] # 文章详情
|
||
/posts/[id]/edit # 编辑文章 (新增,需认证)
|
||
/posts/new # 发布新文章 (需认证)
|
||
/drafts # 我的草稿列表 (新增,需认证)
|
||
/404 # 404 页面
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 开发路线图 (续)
|
||
|
||
接续 Part 1 的开发顺序:
|
||
|
||
6. **Step 6: Register Page** — 完成注册页面,复用登录页样式。
|
||
7. **Step 7: Edit Flow** — 完成文章编辑功能,实现 `PUT /posts/:id`。
|
||
8. **Step 8: Delete Flow** — 完成删除功能,实现 Inline Confirmation 模式。
|
||
9. **Step 9: Draft Management** — 完成草稿列表页,支持发布/取消发布切换。
|
||
10. **Step 10: Auth Guard** — 添加全局认证守卫,保护需要登录的路由。
|
||
11. **Step 11: Polish** — 统一错误处理、Loading 状态、过渡动画。
|
||
|
||
---
|
||
|
||
## 8. 完整 API 客户端 (Final api.ts)
|
||
|
||
汇总 Part 1 + Part 2 的完整 API 客户端:
|
||
|
||
```typescript
|
||
// src/lib/api.ts
|
||
|
||
import { auth } from './auth';
|
||
import type {
|
||
LoginRequest,
|
||
RegisterRequest,
|
||
AuthResponse,
|
||
RegisterResponse,
|
||
Post,
|
||
UpdatePostRequest,
|
||
DeleteResponse,
|
||
PageParams,
|
||
} from '@/types/api';
|
||
|
||
const BASE_URL = 'http://127.0.0.1:8765/api';
|
||
|
||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
const token = auth.getToken();
|
||
|
||
const headers: HeadersInit = {
|
||
'Content-Type': 'application/json',
|
||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||
...options.headers,
|
||
};
|
||
|
||
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||
...options,
|
||
headers,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
auth.clear();
|
||
window.location.href = '/login';
|
||
}
|
||
const errorBody = await response.json().catch(() => ({}));
|
||
throw {
|
||
status: response.status,
|
||
message: response.statusText,
|
||
...errorBody
|
||
};
|
||
}
|
||
|
||
if (response.status === 204) return {} as T;
|
||
return response.json();
|
||
}
|
||
|
||
export const api = {
|
||
// ===== Auth =====
|
||
login: (data: LoginRequest) => request<AuthResponse>('/auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
}),
|
||
|
||
register: (data: RegisterRequest) => request<RegisterResponse>('/auth/register', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
}),
|
||
|
||
logout: () => auth.clear(),
|
||
|
||
// ===== Posts - Read =====
|
||
getPosts: (params: PageParams) => {
|
||
const query = new URLSearchParams({
|
||
page: params.page.toString(),
|
||
limit: params.limit.toString()
|
||
});
|
||
return request<Post[]>(`/posts?${query}`);
|
||
},
|
||
|
||
getPostById: (id: string) => request<Post>(`/posts/${id}`),
|
||
|
||
// ===== Posts - Write =====
|
||
createPost: (data: Partial<Post>) => request<Post>('/posts', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
}),
|
||
|
||
updatePost: (id: string, data: UpdatePostRequest) => request<Post>(`/posts/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data),
|
||
}),
|
||
|
||
deletePost: (id: string) => request<DeleteResponse>(`/posts/${id}`, {
|
||
method: 'DELETE',
|
||
}),
|
||
|
||
// ===== Posts - Convenience =====
|
||
publishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ published: true }),
|
||
}),
|
||
|
||
unpublishPost: (id: string) => request<Post>(`/posts/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ published: false }),
|
||
}),
|
||
|
||
// ===== Upload =====
|
||
uploadImage: async (file: File) => {
|
||
const token = auth.getToken();
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await fetch(`${BASE_URL}/upload`, {
|
||
method: 'POST',
|
||
headers: {
|
||
...(token && { 'Authorization': `Bearer ${token}` })
|
||
},
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
auth.clear();
|
||
window.location.href = '/login';
|
||
}
|
||
throw new Error('Upload failed');
|
||
}
|
||
return response.json();
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
现在 Part 1 + Part 2 完整覆盖了你的 OpenAPI 规范中的所有端点。
|