Files
Rs_blog_front/docs/apidev_part2.md
2026-02-12 00:34:21 +08:00

13 KiB
Raw Blame History

🔌 后端对接集成文档 Part 2 (API Integration Spec - Extended)

延续文档: apidev_part1.md 覆盖功能: 注册、文章更新/删除、认证管理、草稿流程


1. 补充数据模型 (Extended TypeScript Interfaces)

src/types/api.ts 中追加以下接口:

// 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 中追加以下方法:

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 后跳转登录页

组件实现建议:

// 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  ->  编辑页面

数据加载流程:

// 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"

极简自动保存指示器:

// 不要用 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):

// 第一次点击:文字变化
// 第二次点击:执行删除

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:

// 极简 Toggle不用开关组件用文字按钮
<button
  onClick={() => api.updatePost(id, { published: !isPublished })}
  className="text-sm text-neutral-500 hover:text-black transition-colors"
>
  {isPublished ? "Unpublish" : "Publish"}
</button>

列表页草稿标识:

// 草稿文章标题后显示灰色 (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 存储策略

// 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)

// 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 函数:

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 的开发顺序:

  1. Step 6: Register Page — 完成注册页面,复用登录页样式。
  2. Step 7: Edit Flow — 完成文章编辑功能,实现 PUT /posts/:id
  3. Step 8: Delete Flow — 完成删除功能,实现 Inline Confirmation 模式。
  4. Step 9: Draft Management — 完成草稿列表页,支持发布/取消发布切换。
  5. Step 10: Auth Guard — 添加全局认证守卫,保护需要登录的路由。
  6. Step 11: Polish — 统一错误处理、Loading 状态、过渡动画。

8. 完整 API 客户端 (Final api.ts)

汇总 Part 1 + Part 2 的完整 API 客户端:

// 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 规范中的所有端点。