React+Ts+Vite 前端demo完成后加后端接口
·
1. 新建文件
1. server/index.mjs
Node HTTP 服务,提供 REST API,各API路由说明,读写 data.json
/**
* 看板 Node.js 后端服务
* 使用 data.json 作为持久化存储,提供 REST API 供前端调用
*/
import { createServer } from 'node:http';
import { readFileSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** 看板数据文件路径 */
const DATA_FILE = join(__dirname, 'data.json');
/** 云平台会注入 PORT,本地默认 3001 */
const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST || '0.0.0.0';
/** 允许跨域的前端地址(GitHub Pages / 本地 Vite) */
const ALLOWED_ORIGINS = (
process.env.ALLOWED_ORIGINS ??
'http://localhost:5173,https://zhangyuehan321.github.io'
)
.split(',')
.map(origin => origin.trim())
.filter(Boolean);
function setCors(req, res) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
/** 从 data.json 读取看板数据 */
function readData() {
return JSON.parse(readFileSync(DATA_FILE, 'utf-8'));
}
/** 将看板数据写回 data.json */
function writeData(data) {
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
}
/** 解析 POST/PATCH 请求体 */
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk;
});
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
reject(error);
}
});
});
}
function sendJson(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
const server = createServer(async (req, res) => {
setCors(req, res);
// 浏览器跨域预检请求
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const { pathname } = url;
const { method } = req;
try {
// GET /api/boards — 获取全部分组及任务
if (method === 'GET' && pathname === '/api/boards') {
const data = readData();
return sendJson(res, 200, data.boards);
}
// POST /api/boards — 创建新分组
if (method === 'POST' && pathname === '/api/boards') {
const body = await parseBody(req);
const data = readData();
const board = {
groupId: randomUUID(),
groupName: body.groupName || `分组${data.boards.length + 1}`,
tasks: []
};
data.boards.push(board);
writeData(data);
return sendJson(res, 201, board);
}
// POST /api/boards/:groupId/tasks — 在指定分组下创建任务
const taskMatch = pathname.match(/^\/api\/boards\/([^/]+)\/tasks$/);
if (method === 'POST' && taskMatch) {
const groupId = taskMatch[1];
const body = await parseBody(req);
const data = readData();
const board = data.boards.find(item => item.groupId === groupId);
if (!board) {
return sendJson(res, 404, { message: 'Group not found' });
}
const task = {
id: randomUUID(),
title: body.title || `新任务${board.tasks.length + 1}`
};
board.tasks.push(task);
writeData(data);
return sendJson(res, 201, task);
}
// PATCH /api/tasks/reorder — 同分组内调整任务顺序
if (method === 'PATCH' && pathname === '/api/tasks/reorder') {
const body = await parseBody(req);
const { groupId, activeTaskId, overTaskId } = body;
const data = readData();
const board = data.boards.find(item => item.groupId === groupId);
if (!board) {
return sendJson(res, 404, { message: 'Group not found' });
}
const activeIndex = board.tasks.findIndex(
task => String(task.id) === String(activeTaskId)
);
if (activeIndex === -1) {
return sendJson(res, 404, { message: 'Task not found' });
}
if (overTaskId) {
const overIndex = board.tasks.findIndex(
task => String(task.id) === String(overTaskId)
);
if (overIndex === -1) {
return sendJson(res, 404, {
message: 'Target task not found'
});
}
if (activeIndex !== overIndex) {
const tasks = board.tasks.slice();
const [moved] = tasks.splice(activeIndex, 1);
tasks.splice(overIndex, 0, moved);
board.tasks = tasks;
}
} else {
const [task] = board.tasks.splice(activeIndex, 1);
board.tasks.push(task);
}
writeData(data);
return sendJson(res, 200, data.boards);
}
// PATCH /api/tasks/move — 跨分组移动任务
if (method === 'PATCH' && pathname === '/api/tasks/move') {
const body = await parseBody(req);
const { taskId, fromGroupId, toGroupId } = body;
const data = readData();
const fromBoard = data.boards.find(
item => item.groupId === fromGroupId
);
const toBoard = data.boards.find(
item => item.groupId === toGroupId
);
if (!fromBoard || !toBoard) {
return sendJson(res, 404, { message: 'Group not found' });
}
const taskIndex = fromBoard.tasks.findIndex(
task => String(task.id) === String(taskId)
);
if (taskIndex === -1) {
return sendJson(res, 404, { message: 'Task not found' });
}
const [task] = fromBoard.tasks.splice(taskIndex, 1);
if (
!toBoard.tasks.some(item => String(item.id) === String(taskId))
) {
toBoard.tasks.push(task);
}
writeData(data);
return sendJson(res, 200, data.boards);
}
// PATCH /api/boards/reorder — 调整分组(列)顺序
if (method === 'PATCH' && pathname === '/api/boards/reorder') {
const body = await parseBody(req);
const { activeGroupId, overGroupId } = body;
const data = readData();
const fromIndex = data.boards.findIndex(
item => item.groupId === activeGroupId
);
const toIndex = data.boards.findIndex(
item => item.groupId === overGroupId
);
if (fromIndex === -1 || toIndex === -1) {
return sendJson(res, 404, { message: 'Group not found' });
}
const [moved] = data.boards.splice(fromIndex, 1);
data.boards.splice(toIndex, 0, moved);
writeData(data);
return sendJson(res, 200, data.boards);
}
return sendJson(res, 404, { message: 'Not found' });
} catch (error) {
const message =
error instanceof Error ? error.message : 'Internal server error';
return sendJson(res, 500, { message });
}
});
server.listen(PORT, HOST, () => {
console.log(`Kanban API server running at http://${HOST}:${PORT}`);
});
2. server/data.json
项目中需要的数据
{
"boards": [
{
"groupId": "group-1",
"groupName": "待办",
"tasks": [
{
"id": "7b87a3fe-104d-42db-8a39-7d05d5524024",
"title": "新任务2"
},
{
"id": "6d0f5499-7d82-4070-b17e-25ad64862063",
"title": "新任务2"
}
]
},
{
"groupId": "group-2",
"groupName": "进行中",
"tasks": [
{
"id": "task-3",
"title": "开发后端接口"
},
{
"id": "task-2",
"title": "接入拖拽功能"
},
{
"id": "386dabd4-dd89-4840-8c8b-3888afa1adb5",
"title": "新任务3"
}
]
},
{
"groupId": "group-3",
"groupName": "已完成",
"tasks": [
{
"id": "task-1",
"title": "设计看板原型"
}
]
}
]
}
3. src/api/kanban.ts
前端API封装,fetch调用。接口用途与本地/线上差异
GET /api/boards 获取全部分组
POST /api/boards 创建分组
POST /api/boards/:id/tasks 创建任务
PATCH /api/tasks/move 跨分组移动任务
PATCH /api/boards/reorder 调整分组顺序
/**
* 看板后端 API 封装
* 本地开发:请求 /api/*,由 Vite 代理到 server/index.mjs
* 线上部署:通过 VITE_API_URL 指向 Render 等平台的 API 地址
*/
import type { Board, Task } from '@/types/kanban';
// 生产环境 API 根地址,未配置时使用相对路径(走本地代理)
const API_BASE = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
const apiUrl = (path: string) => `${API_BASE}${path}`;
/** 统一 fetch 封装,处理 JSON 与错误 */
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export const kanbanApi = {
/** 获取全部分组及任务(页面初始加载) */
getBoards: () => request<Board[]>(apiUrl('/api/boards')),
/** 创建新分组 */
createBoard: (groupName: string) =>
request<Board>(apiUrl('/api/boards'), {
method: 'POST',
body: JSON.stringify({ groupName })
}),
/** 在指定分组下创建任务 */
createTask: (groupId: string, title?: string) =>
request<Task>(apiUrl(`/api/boards/${groupId}/tasks`), {
method: 'POST',
body: JSON.stringify({ title })
}),
/** 跨分组移动任务 */
moveTask: (taskId: string, fromGroupId: string, toGroupId: string) =>
request<Board[]>(apiUrl('/api/tasks/move'), {
method: 'PATCH',
body: JSON.stringify({ taskId, fromGroupId, toGroupId })
}),
/** 同分组内调整任务顺序 */
reorderTask: (groupId: string, activeTaskId: string, overTaskId?: string) =>
request<Board[]>(apiUrl('/api/tasks/reorder'), {
method: 'PATCH',
body: JSON.stringify({ groupId, activeTaskId, overTaskId })
}),
/** 调整分组(列)顺序 */
reorderGroups: (activeGroupId: string, overGroupId: string) =>
request<Board[]>(apiUrl('/api/boards/reorder'), {
method: 'PATCH',
body: JSON.stringify({ activeGroupId, overGroupId })
})
};
4. src/types/kanban.ts
Board, Task类型定义
/** 看板任务 */
export type Task = {
id: string;
title: string;
};
/** 看板分组(列),包含一组任务 */
export type Board = {
groupId: string;
groupName: string;
tasks: Task[];
};
2. 重构
1. src/stores/useKanBan.ts
从纯 Zustand 本地改异步调 API;新增 fetchBoards、loading、error。与后端同步的状态逻辑
/**
* 看板状态管理(Zustand)
* 改造后:读写操作均通过 kanbanApi 调用后端,不再纯前端内存维护
*/
import { create } from 'zustand';
import { kanbanApi } from '@/api/kanban';
import type { Board } from '@/types/kanban';
export const useKanBan = create<{
boards: Board[];
loading: boolean;
error: string | null;
fetchBoards: () => Promise<void>;
createBoard: (groupName: string) => Promise<void>;
createTask: (groupId: string) => Promise<void>;
moveTask: (
taskId: string,
fromGroupId: string,
toGroupId: string
) => Promise<void>;
reorderTask: (
groupId: string,
activeTaskId: string,
overTaskId?: string
) => Promise<void>;
moveGroup: (activeGroupId: string, overGroupId: string) => Promise<void>;
}>(set => ({
boards: [],
loading: false,
error: null,
/** 从后端拉取初始看板数据 */
fetchBoards: async () => {
set({ loading: true, error: null });
try {
const boards = await kanbanApi.getBoards();
set({ boards, loading: false });
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : '加载看板失败'
});
}
},
/** 创建分组并同步到后端 */
createBoard: async groupName => {
const board = await kanbanApi.createBoard(groupName);
set(state => ({ boards: [...state.boards, board] }));
},
/** 创建任务并同步到后端 */
createTask: async groupId => {
const task = await kanbanApi.createTask(groupId);
set(state => ({
boards: state.boards.map(board =>
board.groupId === groupId
? { ...board, tasks: [...board.tasks, task] }
: board
)
}));
},
/** 跨分组移动任务,用后端返回的最新列表更新状态 */
moveTask: async (taskId, fromGroupId, toGroupId) => {
const boards = await kanbanApi.moveTask(taskId, fromGroupId, toGroupId);
set({ boards });
},
/** 同分组内排序任务 */
reorderTask: async (groupId, activeTaskId, overTaskId) => {
const boards = await kanbanApi.reorderTask(
groupId,
activeTaskId,
overTaskId
);
set({ boards });
},
/** 调整分组顺序 */
moveGroup: async (activeGroupId, overGroupId) => {
const boards = await kanbanApi.reorderGroups(
activeGroupId,
overGroupId
);
set({ boards });
}
}));
2. src/pages/Borad/index.tsx
进入页面 fetchBoards();加载中 / 错误 / 重试 UI。初始加载逻辑
import { useEffect } from 'react';
import { useKanBan } from '@/stores/useKanBan';
import { Button } from '@/components/ui/button';
import { Board } from '@/components/Board';
export const BoardPage = () => {
const { boards, loading, error, fetchBoards, createBoard } = useKanBan();
// 进入页面时从后端接口加载看板数据
useEffect(() => {
fetchBoards();
}, [fetchBoards]);
if (loading) {
return <div className="p-4">加载中...</div>;
}
if (error) {
return (
<div className="p-4">
<p className="mb-2 text-red-500">{error}</p>
<Button onClick={() => fetchBoards()}>重试</Button>
</div>
);
}
return (
<div className="flex flex-row flex-wrap gap-4 p-4">
<Button onClick={() => createBoard(`分组${boards.length + 1}`)}>
创建分组
</Button>
<Board />
</div>
);
};
3. 修改
1. vite.config.ts
开发代理 /api → localhost:3001。开发代理说明
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
resolve: {
alias: {
// '@': '/src',
'@': path.resolve(__dirname, 'src')
}
},
server: {
// 开发环境:将 /api 请求代理到本地 Node 后端(server/index.mjs)
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
},
plugins: [react(), tailwindcss()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}']
}
});
2. package.json
新增 server、dev:all 脚本
"scripts": {
"dev": "vite",
"server": "node server/index.mjs",
"start": "node server/index.mjs",
"dev:all": "node server/index.mjs & vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"preview": "vite preview",
"prepare": "husky"
},
4. 架构变化
之前:Zustand 内存 → 刷新丢失
之后:页面 load → GET /api/boards → 操作 → PATCH/POST → 写入 data.json
更多推荐
所有评论(0)