feature: add draft

This commit is contained in:
dela
2026-02-12 00:34:21 +08:00
parent 788de594f2
commit 5a618aac12
18 changed files with 1262 additions and 59 deletions

551
docs/apidev_part2.md Normal file
View File

@@ -0,0 +1,551 @@
# 🔌 后端对接集成文档 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 规范中的所有端点。